feat: Implement cross-tenant access functionality

- Added configuration for enabling cross-tenant access in the application.
- Introduced new API endpoints for listing and searching tenants, accessible only to users with the appropriate permissions.
- Updated user and tenant models to include the `can_access_all_tenants` field.
- Enhanced the authentication middleware to handle cross-tenant requests and validate user permissions.
- Created a new TenantSelector component for selecting tenants in the frontend, improving user experience.
- Updated internationalization files to support new tenant-related strings and messages.
This commit is contained in:
wizardchen
2025-12-02 12:17:39 +08:00
parent e66ace9c14
commit cdf576eb18
22 changed files with 1079 additions and 29 deletions

View File

@@ -613,3 +613,8 @@ web_search:
# 全局超时设置
timeout: 10
# 租户配置
tenant:
# 是否启用跨租户访问功能(内网环境可开启)
enable_cross_tenant_access: true

View File

@@ -15,6 +15,7 @@ export interface LoginResponse {
email: string
avatar?: string
tenant_id: number
can_access_all_tenants?: boolean
is_active: boolean
created_at: string
updated_at: string
@@ -66,6 +67,7 @@ export interface UserInfo {
email: string
avatar?: string
tenant_id: string
can_access_all_tenants?: boolean
created_at: string
updated_at: string
}

View File

@@ -0,0 +1,83 @@
import { get } from '@/utils/request'
// 租户信息接口
export interface TenantInfo {
id: number
name: string
description?: string
api_key?: string
status?: string
business?: string
storage_quota?: number
storage_used?: number
created_at: string
updated_at: string
}
// 搜索租户参数
export interface SearchTenantsParams {
keyword?: string
tenant_id?: number
page?: number
page_size?: number
}
// 搜索租户响应
export interface SearchTenantsResponse {
success: boolean
data?: {
items: TenantInfo[]
total: number
page: number
page_size: number
}
message?: string
}
/**
* 获取所有租户列表(需要跨租户访问权限)
* @deprecated 建议使用 searchTenants 代替,支持分页和搜索
*/
export async function listAllTenants(): Promise<{ success: boolean; data?: { items: TenantInfo[] }; message?: string }> {
try {
const response = await get('/api/v1/tenants/all')
return response as unknown as { success: boolean; data?: { items: TenantInfo[] }; message?: string }
} catch (error: any) {
return {
success: false,
message: error.message || '获取租户列表失败'
}
}
}
/**
* 搜索租户支持分页、关键词搜索和租户ID过滤
*/
export async function searchTenants(params: SearchTenantsParams = {}): Promise<SearchTenantsResponse> {
try {
const queryParams = new URLSearchParams()
if (params.keyword) {
queryParams.append('keyword', params.keyword)
}
if (params.tenant_id) {
queryParams.append('tenant_id', String(params.tenant_id))
}
if (params.page) {
queryParams.append('page', String(params.page))
}
if (params.page_size) {
queryParams.append('page_size', String(params.page_size))
}
const queryString = queryParams.toString()
const url = `/api/v1/tenants/search${queryString ? '?' + queryString : ''}`
const response = await get(url)
return response as unknown as SearchTenantsResponse
} catch (error: any) {
return {
success: false,
message: error.message || '搜索租户失败'
}
}
}

View File

@@ -0,0 +1,534 @@
<template>
<div class="tenant-selector">
<div v-if="canAccessAllTenants" class="tenant-selector-wrapper">
<div class="tenant-menu-item" @click="toggleDropdown" ref="triggerRef">
<div class="tenant-item-box">
<div class="tenant-icon">
<t-icon name="usergroup" size="16px" />
</div>
<span class="tenant-title">{{ currentTenantName }}</span>
</div>
<t-icon name="chevron-up" class="tenant-arrow" :class="{ rotated: showDropdown }" />
</div>
<div v-if="showDropdown" class="tenant-overlay" @click="close">
<div class="tenant-dropdown" @click.stop :style="dropdownStyle">
<div class="tenant-list" ref="tenantList">
<div
v-for="tenant in tenants"
:key="tenant.id"
:class="['tenant-item', { selected: isSelected(tenant.id) }]"
@click="selectTenant(tenant.id)"
>
<div class="tenant-item-info">
<span class="tenant-item-name">{{ tenant.name }}</span>
<span v-if="tenant.description" class="tenant-item-desc">{{ tenant.description }}</span>
<span class="tenant-item-id">ID: {{ tenant.id }}</span>
</div>
<t-icon v-if="isSelected(tenant.id)" name="check" size="16px" class="tenant-check-icon" />
</div>
<div v-if="tenants.length === 0 && !loading" class="tenant-empty">
{{ $t('tenant.noMatch') }}
</div>
<div v-if="loading" class="tenant-loading">
{{ $t('tenant.loading') }}
</div>
<div v-if="hasMore && !loading" class="tenant-load-more" @click="loadMore">
{{ $t('tenant.loadMore') }}
</div>
</div>
<div class="tenant-search">
<input
ref="searchInput"
v-model="searchQuery"
type="text"
:placeholder="$t('tenant.searchPlaceholder')"
class="tenant-search-input"
@keydown.esc="closeDropdown"
@input="handleSearchInput"
/>
<div class="tenant-search-hint">
{{ $t('tenant.searchHint') }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="tenant-menu-item readonly">
<div class="tenant-item-box">
<div class="tenant-icon">
<t-icon name="usergroup" size="16px" />
</div>
<span class="tenant-title">{{ currentTenantName }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onUnmounted, nextTick } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { searchTenants, type TenantInfo } from '@/api/tenant'
import { useI18n } from 'vue-i18n'
import { MessagePlugin } from 'tdesign-vue-next'
const { t } = useI18n()
const authStore = useAuthStore()
const showDropdown = ref(false)
const searchQuery = ref('')
const tenants = ref<TenantInfo[]>([])
const triggerRef = ref<HTMLElement | null>(null)
const tenantList = ref<HTMLElement | null>(null)
const searchInput = ref<HTMLInputElement | null>(null)
const dropdownStyle = ref<Record<string, string>>({})
// 分页相关
const currentPage = ref(1)
const pageSize = ref(20)
const total = ref(0)
const loading = ref(false)
const searchTimer = ref<number | null>(null)
const canAccessAllTenants = computed(() => authStore.canAccessAllTenants)
const selectedTenantId = computed(() => authStore.selectedTenantId)
const defaultTenantId = computed(() => authStore.tenant?.id ? Number(authStore.tenant.id) : null)
const currentTenantId = computed(() => {
return selectedTenantId.value || defaultTenantId.value
})
const currentTenantName = computed(() => {
if (!currentTenantId.value) return t('tenant.unknown')
const tenant = tenants.value.find(t => t.id === currentTenantId.value)
if (tenant) return tenant.name
return authStore.tenant?.name || t('tenant.unknown')
})
const hasMore = computed(() => {
return tenants.value.length < total.value
})
const isSelected = (tenantId: number) => {
return currentTenantId.value === tenantId
}
const updateDropdownPosition = () => {
if (!triggerRef.value) return
const rect = triggerRef.value.getBoundingClientRect()
const dropdownMaxHeight = 400 // max-height
const spaceAbove = rect.top
const padding = 16
// 计算可用高度,确保有足够空间显示搜索框
const availableHeight = Math.min(dropdownMaxHeight, spaceAbove - padding)
// 确保最小高度(至少能显示搜索框和一些列表项)
const minHeight = 200
const finalHeight = Math.max(minHeight, availableHeight)
// 向上弹出,确保不遮挡用户头像
dropdownStyle.value = {
bottom: `${window.innerHeight - rect.top + 8}px`,
left: `${rect.left}px`,
width: '280px',
height: `${finalHeight}px`,
maxHeight: `${finalHeight}px`
}
}
const toggleDropdown = () => {
if (!canAccessAllTenants.value) return
showDropdown.value = !showDropdown.value
if (showDropdown.value) {
if (tenants.value.length === 0) {
loadTenants()
}
nextTick(() => {
updateDropdownPosition()
searchInput.value?.focus()
})
} else {
// 关闭时重置搜索
searchQuery.value = ''
currentPage.value = 1
tenants.value = []
total.value = 0
}
}
const closeDropdown = () => {
showDropdown.value = false
searchQuery.value = ''
currentPage.value = 1
tenants.value = []
total.value = 0
if (searchTimer.value) {
clearTimeout(searchTimer.value)
searchTimer.value = null
}
}
const close = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.tenant-dropdown') && !target.closest('.tenant-menu-item')) {
closeDropdown()
}
}
const selectTenant = (tenantId: number) => {
// 如果选择的是默认租户,清除选择
if (tenantId === defaultTenantId.value) {
authStore.setSelectedTenant(null)
} else {
authStore.setSelectedTenant(tenantId)
}
closeDropdown()
// 触发页面刷新以加载新租户的数据
MessagePlugin.success(t('tenant.switchSuccess'))
setTimeout(() => {
window.location.reload()
}, 500)
}
const loadTenants = async (append = false) => {
if (loading.value) return
loading.value = true
try {
// 解析搜索关键词判断是否是租户ID
let keyword = searchQuery.value.trim()
let tenantID: number | undefined = undefined
// 如果搜索关键词是纯数字尝试作为租户ID查询
if (keyword && /^\d+$/.test(keyword)) {
tenantID = Number(keyword)
keyword = '' // 清空关键词使用租户ID查询
}
const response = await searchTenants({
keyword: keyword || undefined,
tenant_id: tenantID,
page: currentPage.value,
page_size: pageSize.value
})
if (response.success && response.data) {
if (append) {
tenants.value = [...tenants.value, ...response.data.items]
} else {
tenants.value = response.data.items
}
total.value = response.data.total
authStore.setAllTenants(tenants.value)
} else {
MessagePlugin.error(response.message || t('tenant.loadTenantsFailed'))
}
} catch (error) {
console.error('Failed to load tenants:', error)
MessagePlugin.error(t('tenant.loadTenantsFailed'))
} finally {
loading.value = false
}
}
const handleSearchInput = () => {
// 防抖处理延迟500ms后搜索
if (searchTimer.value) {
clearTimeout(searchTimer.value)
}
searchTimer.value = window.setTimeout(() => {
currentPage.value = 1
tenants.value = []
total.value = 0
loadTenants()
}, 500)
}
const loadMore = () => {
if (hasMore.value && !loading.value) {
currentPage.value++
loadTenants(true)
}
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!target.closest('.tenant-selector-wrapper')) {
closeDropdown()
}
}
const handleResize = () => {
if (showDropdown.value) {
updateDropdownPosition()
}
}
watch(showDropdown, (newVal) => {
if (newVal) {
document.addEventListener('click', handleClickOutside)
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize, true)
updateDropdownPosition()
} else {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
}
})
onMounted(() => {
// 不再自动加载,等用户打开下拉框时再加载
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
if (searchTimer.value) {
clearTimeout(searchTimer.value)
}
})
</script>
<style scoped lang="less">
.tenant-selector {
width: 100%;
margin-bottom: 4px;
}
.tenant-selector-wrapper {
width: 100%;
position: relative;
}
.tenant-menu-item {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 13px 8px 13px 16px;
box-sizing: border-box;
transition: all 0.2s;
&:hover {
border-radius: 4px;
background: #30323605;
color: #00000099;
.tenant-icon,
.tenant-title {
color: #00000099;
}
}
&.readonly {
cursor: default;
&:hover {
background: transparent;
}
}
}
.tenant-item-box {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.tenant-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: #00000099;
flex-shrink: 0;
}
.tenant-title {
font-size: 14px;
font-weight: 400;
color: #000000e6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
}
.tenant-arrow {
font-size: 16px;
color: #00000066;
flex-shrink: 0;
margin-left: 8px;
transition: transform 0.2s;
&.rotated {
transform: rotate(180deg);
}
}
.tenant-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.tenant-dropdown {
position: fixed;
background: #fff;
border: 1px solid #e7e9eb;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
transform-origin: bottom left;
box-sizing: border-box;
}
.tenant-search {
padding: 8px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
flex-shrink: 0;
box-sizing: border-box;
}
.tenant-search-input {
width: 100%;
padding: 6px 8px;
border: 1px solid #e7e9eb;
border-radius: 4px;
font-size: 14px;
outline: none;
background: #fff;
color: #333;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: #07c05f;
}
&::placeholder {
color: #999;
}
}
.tenant-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 0;
min-height: 0;
max-height: 100%;
box-sizing: border-box;
}
.tenant-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
&.selected {
background: #07c05f1a;
.tenant-item-name {
color: #07c05f;
font-weight: 500;
}
}
}
.tenant-item-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tenant-item-name {
font-size: 14px;
color: #333;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tenant-item-desc {
font-size: 12px;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tenant-item-id {
font-size: 11px;
color: #999;
margin-top: 2px;
}
.tenant-loading {
padding: 16px;
text-align: center;
color: #999;
font-size: 14px;
}
.tenant-load-more {
padding: 12px;
text-align: center;
color: #07c05f;
font-size: 14px;
cursor: pointer;
border-top: 1px solid #f0f0f0;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
.tenant-search-hint {
font-size: 11px;
color: #999;
margin-top: 4px;
padding: 0 2px;
}
.tenant-check-icon {
color: #07c05f;
flex-shrink: 0;
margin-left: 8px;
}
.tenant-empty {
padding: 16px;
text-align: center;
color: #999;
font-size: 14px;
}
</style>

View File

@@ -205,10 +205,33 @@ const loadUserInfo = async () => {
try {
const response = await getCurrentUser()
if (response.success && response.data && response.data.user) {
const user = response.data.user
userInfo.value = {
username: response.data.user.username || t('common.info'),
email: response.data.user.email || 'user@example.com',
avatar: response.data.user.avatar || ''
username: user.username || t('common.info'),
email: user.email || 'user@example.com',
avatar: user.avatar || ''
}
// 同时更新 authStore 中的用户信息,确保包含 can_access_all_tenants 字段
authStore.setUser({
id: user.id,
username: user.username,
email: user.email,
avatar: user.avatar,
tenant_id: user.tenant_id,
can_access_all_tenants: user.can_access_all_tenants || false,
created_at: user.created_at,
updated_at: user.updated_at
})
// 如果返回了租户信息,也更新租户信息
if (response.data.tenant) {
authStore.setTenant({
id: String(response.data.tenant.id),
name: response.data.tenant.name,
api_key: response.data.tenant.api_key || '',
owner_id: user.id,
created_at: response.data.tenant.created_at,
updated_at: response.data.tenant.updated_at
})
}
}
} catch (error) {

View File

@@ -161,6 +161,7 @@
<!-- 下半部分用户菜单 -->
<div class="menu_bottom">
<TenantSelector />
<UserMenu />
</div>
@@ -194,6 +195,7 @@ import { useAuthStore } from '@/stores/auth';
import { useUIStore } from '@/stores/ui';
import { MessagePlugin } from "tdesign-vue-next";
import UserMenu from '@/components/UserMenu.vue';
import TenantSelector from '@/components/TenantSelector.vue';
import { useI18n } from 'vue-i18n';
import { kbFileTypeVerification } from '@/utils';

View File

@@ -1149,6 +1149,7 @@ export default {
},
tenant: {
title: 'Tenant Information',
currentTenant: 'Current Tenant',
sectionDescription: 'View detailed configuration for the tenant',
apiDocument: 'API Document',
name: 'Tenant Name',
@@ -1195,6 +1196,13 @@ export default {
apiKeyCopied: 'API Key copied to clipboard',
unknown: 'Unknown',
formatError: 'Format error',
searchPlaceholder: 'Search by name or enter tenant ID...',
searchHint: 'Search by name or enter tenant ID directly',
noMatch: 'No matching tenants found',
switchSuccess: 'Tenant switched successfully',
loadTenantsFailed: 'Failed to load tenant list',
loading: 'Loading...',
loadMore: 'Load more',
details: {
idLabel: 'Tenant ID',
idDescription: 'Unique identifier of your tenant',

View File

@@ -640,6 +640,7 @@ export default {
},
tenant: {
title: 'Информация об арендаторе',
currentTenant: 'Текущий арендатор',
sectionDescription: 'Просмотр детальной конфигурации арендатора',
apiDocument: 'Документация API',
name: 'Имя арендатора',
@@ -686,6 +687,13 @@ export default {
apiKeyCopied: 'API Key скопирован в буфер обмена',
unknown: 'Неизвестно',
formatError: 'Ошибка формата',
searchPlaceholder: 'Поиск по имени или введите ID арендатора...',
searchHint: 'Поиск по имени или введите ID арендатора напрямую',
noMatch: 'Не найдено подходящих арендаторов',
switchSuccess: 'Арендатор успешно переключен',
loadTenantsFailed: 'Не удалось загрузить список арендаторов',
loading: 'Загрузка...',
loadMore: 'Загрузить еще',
details: {
idLabel: 'ID арендатора',
idDescription: 'Уникальный идентификатор вашего арендатора',

View File

@@ -732,6 +732,7 @@ export default {
},
tenant: {
title: "租户信息",
currentTenant: "当前租户",
sectionDescription: "查看租户的详细配置信息",
apiDocument: "API文档",
name: "租户名称",
@@ -777,6 +778,13 @@ export default {
apiKeyCopied: "API密钥已复制到剪贴板",
unknown: "未知",
formatError: "格式错误",
searchPlaceholder: "搜索租户名称或输入租户ID...",
searchHint: "支持按名称搜索或直接输入租户ID",
noMatch: "未找到匹配的租户",
switchSuccess: "租户切换成功",
loadTenantsFailed: "加载租户列表失败",
loading: "加载中...",
loadMore: "加载更多",
details: {
idLabel: "租户 ID",
idDescription: "您所属租户的唯一标识",

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo, TenantInfo, KnowledgeBaseInfo } from '@/api/auth'
import type { TenantInfo as TenantInfoFromAPI } from '@/api/tenant'
import i18n from '@/i18n'
export const useAuthStore = defineStore('auth', () => {
@@ -11,6 +12,8 @@ export const useAuthStore = defineStore('auth', () => {
const refreshToken = ref<string>('')
const knowledgeBases = ref<KnowledgeBaseInfo[]>([])
const currentKnowledgeBase = ref<KnowledgeBaseInfo | null>(null)
const selectedTenantId = ref<number | null>(null)
const allTenants = ref<TenantInfoFromAPI[]>([])
// 计算属性
const isLoggedIn = computed(() => {
@@ -29,6 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
return user.value?.id || ''
})
const canAccessAllTenants = computed(() => {
return user.value?.can_access_all_tenants || false
})
const effectiveTenantId = computed(() => {
// 如果选择了其他租户使用选择的租户ID否则使用用户默认租户ID
return selectedTenantId.value || (tenant.value?.id ? Number(tenant.value.id) : null)
})
// 操作方法
const setUser = (userData: UserInfo) => {
user.value = userData
@@ -67,6 +79,23 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const setSelectedTenant = (tenantId: number | null) => {
selectedTenantId.value = tenantId
if (tenantId !== null) {
localStorage.setItem('weknora_selected_tenant_id', String(tenantId))
} else {
localStorage.removeItem('weknora_selected_tenant_id')
}
}
const setAllTenants = (tenants: TenantInfoFromAPI[]) => {
allTenants.value = tenants
}
const getSelectedTenant = () => {
return selectedTenantId.value
}
const logout = () => {
// 清空状态
@@ -76,6 +105,8 @@ export const useAuthStore = defineStore('auth', () => {
refreshToken.value = ''
knowledgeBases.value = []
currentKnowledgeBase.value = null
selectedTenantId.value = null
allTenants.value = []
// 清空localStorage
localStorage.removeItem('weknora_user')
@@ -95,6 +126,7 @@ export const useAuthStore = defineStore('auth', () => {
const storedRefreshToken = localStorage.getItem('weknora_refresh_token')
const storedKnowledgeBases = localStorage.getItem('weknora_knowledge_bases')
const storedCurrentKb = localStorage.getItem('weknora_current_kb')
const storedSelectedTenantId = localStorage.getItem('weknora_selected_tenant_id')
if (storedUser) {
try {
@@ -137,6 +169,15 @@ export const useAuthStore = defineStore('auth', () => {
console.error(i18n.global.t('authStore.errors.parseCurrentKnowledgeBaseFailed'), e)
}
}
if (storedSelectedTenantId) {
try {
selectedTenantId.value = Number(storedSelectedTenantId)
} catch (e) {
console.error('Failed to parse selected tenant ID', e)
selectedTenantId.value = null
}
}
}
// 初始化时从localStorage恢复状态
@@ -150,12 +191,16 @@ export const useAuthStore = defineStore('auth', () => {
refreshToken,
knowledgeBases,
currentKnowledgeBase,
selectedTenantId,
allTenants,
// 计算属性
isLoggedIn,
hasValidTenant,
currentTenantId,
currentUserId,
canAccessAllTenants,
effectiveTenantId,
// 方法
setUser,
@@ -164,6 +209,9 @@ export const useAuthStore = defineStore('auth', () => {
setRefreshToken,
setKnowledgeBases,
setCurrentKnowledgeBase,
setSelectedTenant,
setAllTenants,
getSelectedTenant,
logout,
initFromStorage
}

View File

@@ -25,6 +25,22 @@ instance.interceptors.request.use(
config.headers["Authorization"] = `Bearer ${token}`;
}
// 添加跨租户访问请求头(如果选择了其他租户)
const selectedTenantId = localStorage.getItem('weknora_selected_tenant_id');
const defaultTenantId = localStorage.getItem('weknora_tenant');
if (selectedTenantId) {
try {
const defaultTenant = defaultTenantId ? JSON.parse(defaultTenantId) : null;
const defaultId = defaultTenant?.id ? String(defaultTenant.id) : null;
// 如果选择的租户ID与默认租户ID不同添加请求头
if (selectedTenantId !== defaultId) {
config.headers["X-Tenant-ID"] = selectedTenantId;
}
} catch (e) {
console.error('Failed to parse tenant info', e);
}
}
config.headers["X-Request-ID"] = `${generateRandomString(12)}`;
return config;
},

View File

@@ -625,6 +625,7 @@ const handleLogin = async () => {
email: response.user.email || '',
avatar: response.user.avatar,
tenant_id: String(response.tenant.id) || '',
can_access_all_tenants: response.user.can_access_all_tenants || false,
created_at: response.user.created_at || new Date().toISOString(),
updated_at: response.user.updated_at || new Date().toISOString()
})

View File

@@ -52,6 +52,45 @@ func (r *tenantRepository) ListTenants(ctx context.Context) ([]*types.Tenant, er
return tenants, nil
}
// SearchTenants searches tenants with pagination and filters
func (r *tenantRepository) SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error) {
var tenants []*types.Tenant
var total int64
query := r.db.WithContext(ctx).Model(&types.Tenant{})
// Filter by tenant ID if provided
if tenantID > 0 {
query = query.Where("id = ?", tenantID)
}
// Filter by keyword if provided (search in name and description)
if keyword != "" {
query = query.Where("name LIKE ? OR description LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
// Count total
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
query = query.Offset(offset).Limit(pageSize)
}
// Order by created_at DESC
query = query.Order("created_at DESC")
// Execute query
if err := query.Find(&tenants).Error; err != nil {
return nil, 0, err
}
return tenants, total, nil
}
// UpdateTenant updates tenant
func (r *tenantRepository) UpdateTenant(ctx context.Context, tenant *types.Tenant) error {
return r.db.WithContext(ctx).Model(&types.Tenant{}).Where("id = ?", tenant.ID).Updates(tenant).Error

View File

@@ -280,3 +280,49 @@ func (r *tenantService) ExtractTenantIDFromAPIKey(apiKey string) (uint64, error)
return tenantID, nil
}
// ListAllTenants lists all tenants (for users with cross-tenant access permission)
// This method returns all tenants without filtering, intended for admin users
func (s *tenantService) ListAllTenants(ctx context.Context) ([]*types.Tenant, error) {
tenants, err := s.repo.ListTenants(ctx)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
return nil, err
}
logger.Infof(ctx, "All tenants list retrieved successfully, total: %d", len(tenants))
return tenants, nil
}
// SearchTenants searches tenants with pagination and filters
func (s *tenantService) SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error) {
tenants, total, err := s.repo.SearchTenants(ctx, keyword, tenantID, page, pageSize)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"keyword": keyword,
"tenantID": tenantID,
"page": page,
"pageSize": pageSize,
})
return nil, 0, err
}
logger.Infof(ctx, "Tenants search completed, keyword: %s, tenantID: %d, page: %d, pageSize: %d, total: %d, found: %d",
keyword, tenantID, page, pageSize, total, len(tenants))
return tenants, total, nil
}
// GetTenantByIDForUser gets a tenant by ID with permission check
// This method verifies that the user has permission to access the tenant
func (s *tenantService) GetTenantByIDForUser(ctx context.Context, tenantID uint64, userID string) (*types.Tenant, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"user_id": userID,
})
return nil, err
}
return tenant, nil
}

View File

@@ -102,6 +102,8 @@ type TenantConfig struct {
DefaultSessionName string `yaml:"default_session_name" json:"default_session_name"`
DefaultSessionTitle string `yaml:"default_session_title" json:"default_session_title"`
DefaultSessionDescription string `yaml:"default_session_description" json:"default_session_description"`
// EnableCrossTenantAccess enables cross-tenant access for users with permission
EnableCrossTenantAccess bool `yaml:"enable_cross_tenant_access" json:"enable_cross_tenant_access"`
}
// ModelConfig 模型配置

View File

@@ -20,20 +20,23 @@ import (
// Provides functionality for creating, retrieving, updating, and deleting tenants
// through the REST API endpoints
type TenantHandler struct {
service interfaces.TenantService
config *config.Config
service interfaces.TenantService
userService interfaces.UserService
config *config.Config
}
// NewTenantHandler creates a new tenant handler instance with the provided service
// Parameters:
// - service: An implementation of the TenantService interface for business logic
// - userService: An implementation of the UserService interface for user operations
// - config: Application configuration
//
// Returns a pointer to the newly created TenantHandler
func NewTenantHandler(service interfaces.TenantService, config *config.Config) *TenantHandler {
func NewTenantHandler(service interfaces.TenantService, userService interfaces.UserService, config *config.Config) *TenantHandler {
return &TenantHandler{
service: service,
config: config,
service: service,
userService: userService,
config: config,
}
}
@@ -234,6 +237,139 @@ func (h *TenantHandler) ListTenants(c *gin.Context) {
})
}
// ListAllTenants handles the HTTP request for retrieving a list of all tenants
// This endpoint requires cross-tenant access permission
// Parameters:
// - c: Gin context for the HTTP request
func (h *TenantHandler) ListAllTenants(c *gin.Context) {
ctx := c.Request.Context()
// Get current user from context
user, err := h.userService.GetCurrentUser(ctx)
if err != nil {
logger.Errorf(ctx, "Failed to get current user: %v", err)
c.Error(errors.NewUnauthorizedError("Failed to get user information").WithDetails(err.Error()))
return
}
// Check if cross-tenant access is enabled
if h.config == nil || h.config.Tenant == nil || !h.config.Tenant.EnableCrossTenantAccess {
logger.Warnf(ctx, "Cross-tenant access is disabled, user: %s", user.ID)
c.Error(errors.NewForbiddenError("Cross-tenant access is disabled"))
return
}
// Check if user has permission
if !user.CanAccessAllTenants {
logger.Warnf(ctx, "User %s attempted to list all tenants without permission", user.ID)
c.Error(errors.NewForbiddenError("Insufficient permissions to access all tenants"))
return
}
tenants, err := h.service.ListAllTenants(ctx)
if err != nil {
// Check if this is an application-specific error
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to retrieve all tenants list: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to retrieve all tenants list").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"items": tenants,
},
})
}
// SearchTenants handles the HTTP request for searching tenants with pagination
// This endpoint requires cross-tenant access permission
// Query parameters:
// - keyword: search keyword (optional)
// - tenant_id: filter by tenant ID (optional)
// - page: page number (default: 1)
// - page_size: page size (default: 20)
func (h *TenantHandler) SearchTenants(c *gin.Context) {
ctx := c.Request.Context()
// Get current user from context
user, err := h.userService.GetCurrentUser(ctx)
if err != nil {
logger.Errorf(ctx, "Failed to get current user: %v", err)
c.Error(errors.NewUnauthorizedError("Failed to get user information").WithDetails(err.Error()))
return
}
// Check if cross-tenant access is enabled
if h.config == nil || h.config.Tenant == nil || !h.config.Tenant.EnableCrossTenantAccess {
logger.Warnf(ctx, "Cross-tenant access is disabled, user: %s", user.ID)
c.Error(errors.NewForbiddenError("Cross-tenant access is disabled"))
return
}
// Check if user has permission
if !user.CanAccessAllTenants {
logger.Warnf(ctx, "User %s attempted to search tenants without permission", user.ID)
c.Error(errors.NewForbiddenError("Insufficient permissions to access all tenants"))
return
}
// Parse query parameters
keyword := c.Query("keyword")
tenantIDStr := c.Query("tenant_id")
pageStr := c.DefaultQuery("page", "1")
pageSizeStr := c.DefaultQuery("page_size", "20")
var tenantID uint64
if tenantIDStr != "" {
parsedID, err := strconv.ParseUint(tenantIDStr, 10, 64)
if err == nil {
tenantID = parsedID
}
}
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil || pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100 // Limit max page size
}
tenants, total, err := h.service.SearchTenants(ctx, keyword, tenantID, page, pageSize)
if err != nil {
// Check if this is an application-specific error
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to search tenants: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to search tenants").WithDetails(err.Error()))
}
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"items": tenants,
"total": total,
"page": page,
"page_size": pageSize,
},
})
}
// AgentConfigRequest represents the request body for updating agent configuration
type AgentConfigRequest struct {
MaxIterations int `json:"max_iterations"`

View File

@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"slices"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/config"
@@ -37,6 +38,24 @@ func isNoAuthAPI(path string, method string) bool {
return false
}
// canAccessTenant checks if a user can access a target tenant
func canAccessTenant(user *types.User, targetTenantID uint64, cfg *config.Config) bool {
// 1. 检查功能是否启用
if cfg == nil || cfg.Tenant == nil || !cfg.Tenant.EnableCrossTenantAccess {
return false
}
// 2. 检查用户权限
if !user.CanAccessAllTenants {
return false
}
// 3. 如果目标租户是用户自己的租户,允许访问
if user.TenantID == targetTenantID {
return true
}
// 4. 用户有跨租户权限,允许访问(具体验证在中间件中完成)
return true
}
// Auth 认证中间件
func Auth(
tenantService interfaces.TenantService,
@@ -63,10 +82,44 @@ func Auth(
user, err := userService.ValidateToken(c.Request.Context(), token)
if err == nil && user != nil {
// JWT Token认证成功
// 获取租户信息
tenant, err := tenantService.GetTenantByID(c.Request.Context(), user.TenantID)
// 检查是否有跨租户访问请求
targetTenantID := user.TenantID
tenantHeader := c.GetHeader("X-Tenant-ID")
if tenantHeader != "" {
// 解析目标租户ID
parsedTenantID, err := strconv.ParseUint(tenantHeader, 10, 64)
if err == nil {
// 检查用户是否有跨租户访问权限
if canAccessTenant(user, parsedTenantID, cfg) {
// 验证目标租户是否存在
targetTenant, err := tenantService.GetTenantByID(c.Request.Context(), parsedTenantID)
if err == nil && targetTenant != nil {
targetTenantID = parsedTenantID
log.Printf("User %s switching to tenant %d", user.ID, targetTenantID)
} else {
log.Printf("Error getting target tenant by ID: %v, tenantID: %d", err, parsedTenantID)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid target tenant ID",
})
c.Abort()
return
}
} else {
// 用户没有权限访问目标租户
log.Printf("User %s attempted to access tenant %d without permission", user.ID, parsedTenantID)
c.JSON(http.StatusForbidden, gin.H{
"error": "Forbidden: insufficient permissions to access target tenant",
})
c.Abort()
return
}
}
}
// 获取租户信息使用目标租户ID
tenant, err := tenantService.GetTenantByID(c.Request.Context(), targetTenantID)
if err != nil {
log.Printf("Error getting tenant by ID: %v, tenantID: %d, userID: %s", err, user.TenantID, user.ID)
log.Printf("Error getting tenant by ID: %v, tenantID: %d, userID: %s", err, targetTenantID, user.ID)
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Unauthorized: invalid tenant",
})
@@ -75,13 +128,13 @@ func Auth(
}
// 存储用户和租户信息到上下文
c.Set(types.TenantIDContextKey.String(), user.TenantID)
c.Set(types.TenantIDContextKey.String(), targetTenantID)
c.Set(types.TenantInfoContextKey.String(), tenant)
c.Set("user", user)
c.Request = c.Request.WithContext(
context.WithValue(
context.WithValue(
context.WithValue(c.Request.Context(), types.TenantIDContextKey, user.TenantID),
context.WithValue(c.Request.Context(), types.TenantIDContextKey, targetTenantID),
types.TenantInfoContextKey, tenant,
),
"user", user,

View File

@@ -256,6 +256,10 @@ func RegisterChatRoutes(r *gin.RouterGroup, handler *session.Handler) {
// RegisterTenantRoutes 注册租户相关的路由
func RegisterTenantRoutes(r *gin.RouterGroup, handler *handler.TenantHandler) {
// 添加获取所有租户的路由(需要跨租户权限)
r.GET("/tenants/all", handler.ListAllTenants)
// 添加搜索租户的路由(需要跨租户权限,支持分页和搜索)
r.GET("/tenants/search", handler.SearchTenants)
// 租户路由组
tenantRoutes := r.Group("/tenants")
{

View File

@@ -22,6 +22,12 @@ type TenantService interface {
UpdateAPIKey(ctx context.Context, id uint64) (string, error)
// ExtractTenantIDFromAPIKey extracts the tenant ID from the API key
ExtractTenantIDFromAPIKey(apiKey string) (uint64, error)
// ListAllTenants lists all tenants (for users with cross-tenant access permission)
ListAllTenants(ctx context.Context) ([]*types.Tenant, error)
// SearchTenants searches tenants with pagination and filters
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
// GetTenantByIDForUser gets a tenant by ID with permission check
GetTenantByIDForUser(ctx context.Context, tenantID uint64, userID string) (*types.Tenant, error)
}
// TenantRepository defines the tenant repository interface
@@ -32,6 +38,8 @@ type TenantRepository interface {
GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error)
// ListTenants lists all tenants
ListTenants(ctx context.Context) ([]*types.Tenant, error)
// SearchTenants searches tenants with pagination and filters
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
// UpdateTenant updates a tenant
UpdateTenant(ctx context.Context, tenant *types.Tenant) error
// DeleteTenant deletes a tenant

View File

@@ -22,6 +22,8 @@ type User struct {
TenantID uint64 `json:"tenant_id" gorm:"index"`
// Whether the user is active
IsActive bool `json:"is_active" gorm:"default:true"`
// Whether the user can access all tenants (cross-tenant access)
CanAccessAllTenants bool `json:"can_access_all_tenants" gorm:"default:false"`
// Creation time of the user
CreatedAt time.Time `json:"created_at"`
// Last updated time of the user
@@ -89,26 +91,28 @@ type RegisterResponse struct {
// UserInfo represents user information for API responses
type UserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar"`
TenantID uint64 `json:"tenant_id"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Avatar string `json:"avatar"`
TenantID uint64 `json:"tenant_id"`
IsActive bool `json:"is_active"`
CanAccessAllTenants bool `json:"can_access_all_tenants"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ToUserInfo converts User to UserInfo (without sensitive data)
func (u *User) ToUserInfo() *UserInfo {
return &UserInfo{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Avatar: u.Avatar,
TenantID: u.TenantID,
IsActive: u.IsActive,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
ID: u.ID,
Username: u.Username,
Email: u.Email,
Avatar: u.Avatar,
TenantID: u.TenantID,
IsActive: u.IsActive,
CanAccessAllTenants: u.CanAccessAllTenants,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
}

View File

@@ -0,0 +1,10 @@
-- 000016_add_can_access_all_tenants.down.sql
-- Remove can_access_all_tenants column from users table
BEGIN;
ALTER TABLE users
DROP COLUMN IF EXISTS can_access_all_tenants;
COMMIT;

View File

@@ -0,0 +1,10 @@
-- 000016_add_can_access_all_tenants.up.sql
-- Add can_access_all_tenants column to users table
BEGIN;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS can_access_all_tenants BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;