diff --git a/config/config.yaml b/config/config.yaml index 7fb725f05..919b810b8 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -613,3 +613,8 @@ web_search: # 全局超时设置 timeout: 10 + +# 租户配置 +tenant: + # 是否启用跨租户访问功能(内网环境可开启) + enable_cross_tenant_access: true diff --git a/frontend/src/api/auth/index.ts b/frontend/src/api/auth/index.ts index 85efb6a50..45856dc1b 100644 --- a/frontend/src/api/auth/index.ts +++ b/frontend/src/api/auth/index.ts @@ -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 } diff --git a/frontend/src/api/tenant/index.ts b/frontend/src/api/tenant/index.ts new file mode 100644 index 000000000..1bf1e608d --- /dev/null +++ b/frontend/src/api/tenant/index.ts @@ -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 { + 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 || '搜索租户失败' + } + } +} + diff --git a/frontend/src/components/TenantSelector.vue b/frontend/src/components/TenantSelector.vue new file mode 100644 index 000000000..796f04bd7 --- /dev/null +++ b/frontend/src/components/TenantSelector.vue @@ -0,0 +1,534 @@ + + + + + + diff --git a/frontend/src/components/UserMenu.vue b/frontend/src/components/UserMenu.vue index 1ca4fc0b7..969b6fe6b 100644 --- a/frontend/src/components/UserMenu.vue +++ b/frontend/src/components/UserMenu.vue @@ -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) { diff --git a/frontend/src/components/menu.vue b/frontend/src/components/menu.vue index 27906fc56..a1cdb52de 100644 --- a/frontend/src/components/menu.vue +++ b/frontend/src/components/menu.vue @@ -161,6 +161,7 @@ @@ -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'; diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index c2e6a192a..dfd929f01 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -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', diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index 60d2d4be9..70cb38e12 100644 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -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: 'Уникальный идентификатор вашего арендатора', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 9eda16042..2c0953799 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -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: "您所属租户的唯一标识", diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 180ee9a02..48ef88bba 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -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('') const knowledgeBases = ref([]) const currentKnowledgeBase = ref(null) + const selectedTenantId = ref(null) + const allTenants = ref([]) // 计算属性 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 } diff --git a/frontend/src/utils/request.ts b/frontend/src/utils/request.ts index cdac619cc..6c838d603 100644 --- a/frontend/src/utils/request.ts +++ b/frontend/src/utils/request.ts @@ -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; }, diff --git a/frontend/src/views/auth/Login.vue b/frontend/src/views/auth/Login.vue index 2cf47593f..3f5fae96e 100644 --- a/frontend/src/views/auth/Login.vue +++ b/frontend/src/views/auth/Login.vue @@ -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() }) diff --git a/internal/application/repository/tenant.go b/internal/application/repository/tenant.go index e44cd4e4a..9c646f541 100644 --- a/internal/application/repository/tenant.go +++ b/internal/application/repository/tenant.go @@ -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 diff --git a/internal/application/service/tenant.go b/internal/application/service/tenant.go index 04485afdd..47f985209 100644 --- a/internal/application/service/tenant.go +++ b/internal/application/service/tenant.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index a89402b69..317681181 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 模型配置 diff --git a/internal/handler/tenant.go b/internal/handler/tenant.go index 243d2e576..f551f6125 100644 --- a/internal/handler/tenant.go +++ b/internal/handler/tenant.go @@ -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"` diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 1069e7161..de943368a 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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, diff --git a/internal/router/router.go b/internal/router/router.go index e4ca1d1de..303424441 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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") { diff --git a/internal/types/interfaces/tenant.go b/internal/types/interfaces/tenant.go index 1a3320cd6..c5c9c3678 100644 --- a/internal/types/interfaces/tenant.go +++ b/internal/types/interfaces/tenant.go @@ -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 diff --git a/internal/types/user.go b/internal/types/user.go index 5d2f16c5e..333c0055f 100644 --- a/internal/types/user.go +++ b/internal/types/user.go @@ -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, } } diff --git a/migrations/versioned/000016_add_can_access_all_tenants.down.sql b/migrations/versioned/000016_add_can_access_all_tenants.down.sql new file mode 100644 index 000000000..08d5ea69d --- /dev/null +++ b/migrations/versioned/000016_add_can_access_all_tenants.down.sql @@ -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; + diff --git a/migrations/versioned/000016_add_can_access_all_tenants.up.sql b/migrations/versioned/000016_add_can_access_all_tenants.up.sql new file mode 100644 index 000000000..ed0a05ae5 --- /dev/null +++ b/migrations/versioned/000016_add_can_access_all_tenants.up.sql @@ -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; +