feat(tenant): implement tenant creation limit and error handling

Added a new configuration option to limit the number of tenants a non-superuser can create via self-service. Introduced a new error type for handling cases where users exceed this limit, returning a 429 status code. Updated the tenant creation and update handlers to enforce this limit and provide appropriate feedback to users. Additionally, refactored the tenant update request structure to ensure only mutable fields are allowed.

Refs: #1303
This commit is contained in:
wizardchen
2026-05-16 10:37:25 +08:00
committed by lyingbug
parent 29dc99ae79
commit 7d030a6f6a
7 changed files with 497 additions and 698 deletions

View File

@@ -17,29 +17,12 @@ export interface SystemInfo {
db_migration_error?: string
}
export interface ToolDefinition {
name: string
label: string
description: string
}
export interface PlaceholderDefinition {
name: string
label: string
description: string
}
export interface AgentConfig {
max_iterations: number
reflection_enabled: boolean
allowed_tools: string[]
temperature: number
knowledge_bases?: string[]
system_prompt?: string // Unified system prompt (uses {{web_search_status}} placeholder)
available_tools?: ToolDefinition[] // GET 响应中包含POST/PUT 不需要
available_placeholders?: PlaceholderDefinition[] // GET 响应中包含POST/PUT 不需要
}
export interface PromptTemplate {
id: string
name: string
@@ -71,14 +54,6 @@ export function getSystemInfo(): Promise<{ data: SystemInfo }> {
return get('/api/v1/system/info')
}
export function getAgentConfig(): Promise<{ data: AgentConfig }> {
return get('/api/v1/tenants/kv/agent-config')
}
export function updateAgentConfig(config: AgentConfig): Promise<{ data: AgentConfig }> {
return put('/api/v1/tenants/kv/agent-config', config)
}
export function getPromptTemplates(): Promise<{ data: PromptTemplatesConfig }> {
return get('/api/v1/tenants/kv/prompt-templates')
}

View File

@@ -62,10 +62,17 @@ const form = reactive({
description: '',
})
// Trim-aware required checkt-input 的 required 不会去空白,全空格也算
// 通过;这里手动校验 trim 后非空,避免后端因 binding:"required,min=1"
// 才把请求挡下来。max 长度由 <t-input :maxlength="128"> 在键入时硬限制,
// 所以这里不再重复挂规则(避免与硬限制双重提示)。
const formRules: Record<string, FormRule[]> = {
name: [
{ required: true, message: t('tenant.create.nameRequired'), trigger: 'blur' },
{ max: 128, message: t('tenant.create.nameRequired'), trigger: 'blur' },
{
validator: (val: string) => (val ?? '').trim().length > 0,
message: t('tenant.create.nameRequired'),
trigger: 'blur',
},
],
}

File diff suppressed because it is too large Load Diff

View File

@@ -182,6 +182,20 @@ type TenantConfig struct {
// where role lookups are observed but not enforced; flip to true once
// role assignments have been audited.
EnableRBAC bool `yaml:"enable_rbac" json:"enable_rbac"`
// MaxOwnedPerUser caps how many tenants a single non-superuser can
// create (and Own) via self-service POST /tenants. Counts only Owner
// memberships so being invited as Admin/Editor/Viewer in another
// tenant doesn't burn quota. Cross-tenant superusers
// (CanAccessAllTenants) are exempt.
// > 0 — enforce the cap (handler returns 429 when reached).
// = 0 — fall back to defaultMaxOwnedTenantsPerUser in the handler.
// < 0 — disable the cap entirely (not recommended in shared deployments).
//
// Env override: WEKNORA_TENANT_MAX_OWNED_PER_USER (integer). When set
// and parseable it always wins over config.yaml so operators can
// loosen / tighten the quota without a redeploy. See
// applyAuthAndTenantDefaults for the semantics of <0 / 0 / >0.
MaxOwnedPerUser int `yaml:"max_owned_per_user" json:"max_owned_per_user" mapstructure:"max_owned_per_user"`
}
// AuditConfig governs durable audit log behaviour. Writes happen on
@@ -695,6 +709,10 @@ func applyAgentEnvOverrides(cfg *Config) {
// Env overrides (when set and non-empty):
// - WEKNORA_AUTH_REGISTRATION_MODE ("self_serve" or "invite_only")
// - WEKNORA_TENANT_ENABLE_RBAC ("true"/"false", case-insensitive)
// - WEKNORA_TENANT_MAX_OWNED_PER_USER (integer; <0 disables the cap,
// 0 falls back to the handler default, >0 enforces that exact cap).
// Unparseable / empty values are ignored so a stale shell variable
// can't silently disable the quota for a future deployment.
func applyAuthAndTenantDefaults(cfg *Config) {
if cfg.Auth == nil {
cfg.Auth = &AuthConfig{}
@@ -713,6 +731,17 @@ func applyAuthAndTenantDefaults(cfg *Config) {
if value := strings.TrimSpace(os.Getenv("WEKNORA_TENANT_ENABLE_RBAC")); value != "" {
cfg.Tenant.EnableRBAC = strings.EqualFold(value, "true")
}
if value := strings.TrimSpace(os.Getenv("WEKNORA_TENANT_MAX_OWNED_PER_USER")); value != "" {
if n, err := strconv.Atoi(value); err == nil {
cfg.Tenant.MaxOwnedPerUser = n
} else {
fmt.Printf(
"[config] WEKNORA_TENANT_MAX_OWNED_PER_USER=%q is not an integer, ignoring\n",
value,
)
}
}
}
// applyAuditDefaults fills in defaults for the Audit config section

View File

@@ -110,6 +110,19 @@ func NewConflictError(message string) *AppError {
}
}
// NewTooManyRequestsError creates a 429 error, used by quota-style
// guards (e.g. per-user self-service tenant creation cap).
func NewTooManyRequestsError(message string) *AppError {
if message == "" {
message = "too many requests"
}
return &AppError{
Code: ErrTooManyRequests,
Message: message,
HTTPCode: http.StatusTooManyRequests,
}
}
// NewInternalServerError creates an internal server error
func NewInternalServerError(message string) *AppError {
if message == "" {

View File

@@ -7,8 +7,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/Tencent/WeKnora/internal/agent"
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
@@ -76,6 +74,26 @@ type createTenantRequest struct {
Description string `json:"description" binding:"max=512"`
}
// updateTenantRequest is the JSON body for PUT /tenants/:id. Only the
// fields an Owner is permitted to mutate via the public API are bound;
// everything else (storage_quota, status, business, api_key, agent /
// retrieval / storage configs, ...) is intentionally NOT writable here
// — those go through dedicated endpoints (POST /:id/api-key,
// PUT /tenants/kv/:key, ...) that have their own validation.
//
// Pointers so we can distinguish "not sent" from "explicit empty
// string"; when nil we leave the existing column untouched.
type updateTenantRequest struct {
Name *string `json:"name" binding:"omitempty,min=1,max=128"`
Description *string `json:"description" binding:"omitempty,max=512"`
}
// defaultMaxOwnedTenantsPerUser is the cap applied when
// config.Tenant.MaxOwnedPerUser is left at zero. Picked to comfortably
// cover legitimate "personal + a couple of side-projects" use while
// blunting drive-by abuse against POST /tenants (see CreateTenant).
const defaultMaxOwnedTenantsPerUser = 10
// CreateTenant godoc
// @Summary 创建租户
// @Description 创建新的租户。任意已登录用户均可调用以建立自己的新工作区,
@@ -116,6 +134,10 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) {
c.Error(appErr)
return
}
// Reset client-supplied primary key so we don't accidentally
// insert with a chosen ID that collides with a future
// auto-increment value. Tenant IDs must always be DB-generated.
tenantData.ID = 0
} else {
// Self-service path: a regular user can only set name and
// description. Everything else is server-generated by
@@ -128,6 +150,41 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) {
c.Error(appErr)
return
}
// Per-user quota: cap how many tenants a regular user can spin
// up via self-service. Without this any authenticated client
// can flood `tenants` (and saturate validateStorageBucketUniqueness
// which scans the whole table). Superusers above are exempt
// because they're already trusted to manage the catalog.
if h.memberService != nil {
memberships, listErr := h.memberService.ListByUser(ctx, caller.ID)
if listErr != nil {
logger.Errorf(ctx, "Failed to count owned tenants for user %s: %v", caller.ID, listErr)
c.Error(errors.NewInternalServerError("Failed to validate tenant quota").WithDetails(listErr.Error()))
return
}
ownedCount := 0
for _, m := range memberships {
if m != nil && m.Role == types.TenantRoleOwner {
ownedCount++
}
}
cap := defaultMaxOwnedTenantsPerUser
if h.config != nil && h.config.Tenant != nil && h.config.Tenant.MaxOwnedPerUser != 0 {
cap = h.config.Tenant.MaxOwnedPerUser
}
if cap > 0 && ownedCount >= cap {
logger.Warnf(ctx,
"User %s reached self-service tenant quota (%d/%d)",
caller.ID, ownedCount, cap,
)
c.Error(errors.NewTooManyRequestsError(
"reached self-service tenant quota; contact an administrator to raise the limit",
))
return
}
}
tenantData = types.Tenant{
Name: strings.TrimSpace(req.Name),
Description: strings.TrimSpace(req.Description),
@@ -150,18 +207,26 @@ func (h *TenantHandler) CreateTenant(c *gin.Context) {
}
// Bootstrap an Owner membership so the caller immediately has full
// control over the tenant they just created. This mirrors what
// userService.Register does for the auto-created home tenant.
// Idempotent: EnsureOwner is a no-op if the row already exists,
// which lets cross-tenant superusers create-and-own without a
// separate code path. Failure here only logs — the orphan-tenant
// recovery path in middleware/auth.go will recreate the membership
// on next login as a safety net.
// control over the tenant they just created. We MUST roll the tenant
// back if this fails: without a membership row the new tenant is
// unreachable (middleware/auth.go's orphan-recovery only fires for a
// user's home tenant, never for a freshly-created side workspace),
// yet still occupies storage_bucket / name uniqueness slots.
// Idempotent: EnsureOwner is a no-op when the row already exists,
// so cross-tenant superusers create-and-own through the same path.
if h.memberService != nil {
if _, err := h.memberService.EnsureOwner(ctx, caller.ID, createdTenant.ID); err != nil {
logger.Errorf(ctx,
"Failed to bootstrap owner membership for user %s tenant %d: %v",
"Failed to bootstrap owner membership for user %s tenant %d: %v — rolling back tenant",
caller.ID, createdTenant.ID, err)
if delErr := h.service.DeleteTenant(ctx, createdTenant.ID); delErr != nil {
logger.Errorf(ctx,
"Rollback DeleteTenant failed for orphan tenant %d: %v",
createdTenant.ID, delErr,
)
}
c.Error(errors.NewInternalServerError("Failed to finalise tenant ownership").WithDetails(err.Error()))
return
}
}
@@ -242,17 +307,50 @@ func (h *TenantHandler) UpdateTenant(c *gin.Context) {
return
}
var tenantData types.Tenant
if err := c.ShouldBindJSON(&tenantData); err != nil {
// Strict whitelist: only Name / Description are mutable through the
// public PUT. Storage quota, status, business, configs, api_key and
// every other privileged column live behind dedicated endpoints
// (POST /:id/api-key, PUT /tenants/kv/:key, ...). Without this, an
// Owner — including any user who just self-served a tenant — could
// flip status / bump storage_quota by simply crafting an extended
// JSON body. Pointers distinguish "field omitted" from "explicit
// empty string" so we can leave untouched columns alone.
var req updateTenantRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
logger.Infof(ctx, "Updating tenant, ID: %d, Name: %s", id, secutils.SanitizeForLog(tenantData.Name))
// Load the persisted tenant so any column the request omits keeps
// its current value through the GORM `Updates(struct)` zero-skip
// behaviour (we always pass back the full struct).
existing, err := h.service.GetTenantByID(ctx, id)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to load tenant").WithDetails(err.Error()))
}
return
}
tenantData.ID = id
updatedTenant, err := h.service.UpdateTenant(ctx, &tenantData)
if req.Name != nil {
trimmed := strings.TrimSpace(*req.Name)
if trimmed == "" {
c.Error(errors.NewValidationError("name cannot be blank"))
return
}
existing.Name = trimmed
}
if req.Description != nil {
existing.Description = strings.TrimSpace(*req.Description)
}
logger.Infof(ctx, "Updating tenant, ID: %d, Name: %s", id, secutils.SanitizeForLog(existing.Name))
updatedTenant, err := h.service.UpdateTenant(ctx, existing)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
@@ -500,158 +598,9 @@ func (h *TenantHandler) SearchTenants(c *gin.Context) {
})
}
// AgentConfigRequest represents the request body for updating agent configuration
type AgentConfigRequest struct {
MaxIterations int `json:"max_iterations"`
AllowedTools []string `json:"allowed_tools"`
Temperature float64 `json:"temperature"`
SystemPrompt string `json:"system_prompt,omitempty"` // Unified system prompt (uses {{web_search_status}} placeholder)
}
// GetTenantAgentConfig godoc
// @Summary 获取租户Agent配置
// @Description 获取租户的全局Agent配置默认应用于所有会话
// @Tags 租户管理
// @Accept json
// @Produce json
// @Success 200 {object} map[string]interface{} "Agent配置"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /tenants/kv/agent-config [get]
func (h *TenantHandler) GetTenantAgentConfig(c *gin.Context) {
ctx := c.Request.Context()
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
// 从 tools 包集中配置可用工具列表
availableTools := make([]gin.H, 0)
for _, t := range agenttools.AvailableToolDefinitions() {
availableTools = append(availableTools, gin.H{
"name": t.Name,
"label": t.Label,
"description": t.Description,
})
}
// 从 agent 包获取占位符定义
availablePlaceholders := make([]gin.H, 0)
for _, p := range agent.AvailablePlaceholders() {
availablePlaceholders = append(availablePlaceholders, gin.H{
"name": p.Name,
"label": p.Label,
"description": p.Description,
})
}
if tenant.AgentConfig == nil {
// Return default config if not set
logger.Info(ctx, "Tenant has no agent config, returning defaults")
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"max_iterations": agent.DefaultAgentMaxIterations,
"allowed_tools": agenttools.DefaultAllowedTools(),
"temperature": agent.DefaultAgentTemperature,
"system_prompt": agent.GetProgressiveRAGSystemPrompt(h.config),
"use_custom_system_prompt": false,
"available_tools": availableTools,
"available_placeholders": availablePlaceholders,
},
})
return
}
// Get system prompt, use default if empty
systemPrompt := tenant.AgentConfig.ResolveSystemPrompt(true) // webSearchEnabled doesn't matter for unified prompt
if systemPrompt == "" {
systemPrompt = agent.GetProgressiveRAGSystemPrompt(h.config)
}
logger.Infof(ctx, "Retrieved tenant agent config successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"max_iterations": tenant.AgentConfig.MaxIterations,
"allowed_tools": agenttools.DefaultAllowedTools(),
"temperature": tenant.AgentConfig.Temperature,
"system_prompt": systemPrompt,
"use_custom_system_prompt": tenant.AgentConfig.UseCustomSystemPrompt,
"available_tools": availableTools,
"available_placeholders": availablePlaceholders,
},
})
}
// updateTenantAgentConfigInternal updates the agent configuration for a tenant
// This sets the global agent configuration for all sessions in this tenant
func (h *TenantHandler) updateTenantAgentConfigInternal(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start updating tenant agent config")
var req AgentConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error(ctx, "Failed to parse request parameters", err)
c.Error(errors.NewValidationError("Invalid request data").WithDetails(err.Error()))
return
}
// Validate configuration
if req.MaxIterations <= 0 || req.MaxIterations > 30 {
c.Error(errors.NewAgentInvalidMaxIterationsError())
return
}
if req.Temperature < 0 || req.Temperature > 2 {
c.Error(errors.NewAgentInvalidTemperatureError())
return
}
// Get existing tenant
tenant, _ := types.TenantInfoFromContext(ctx)
if tenant == nil {
logger.Error(ctx, "Tenant is empty")
c.Error(errors.NewBadRequestError("Tenant is empty"))
return
}
// Update agent configuration
// Determine if using custom prompt based on whether custom prompts are set
// Support both new unified SystemPrompt and deprecated separate prompts
systemPrompt := req.SystemPrompt
useCustomPrompt := systemPrompt != ""
agentConfig := &types.AgentConfig{
MaxIterations: req.MaxIterations,
AllowedTools: agenttools.DefaultAllowedTools(),
Temperature: req.Temperature,
SystemPrompt: systemPrompt,
UseCustomSystemPrompt: useCustomPrompt,
}
_, err := h.service.UpdateTenant(ctx, tenant)
if err != nil {
if appErr, ok := errors.IsAppError(err); ok {
logger.Error(ctx, "Failed to update tenant: application error", appErr)
c.Error(appErr)
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError("Failed to update tenant agent config").WithDetails(err.Error()))
}
return
}
logger.Infof(ctx, "Tenant agent config updated successfully, Tenant ID: %d", tenant.ID)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": agentConfig,
"message": "Agent configuration updated successfully",
})
}
// GetTenantKV godoc
// @Summary 获取租户KV配置
// @Description 获取租户级别的KV配置支持agent-config、web-search-config
// @Description 获取租户级别的KV配置支持web-search-config、prompt-templates、parser-engine-config、storage-engine-config、chat-history-config、retrieval-config
// @Tags 租户管理
// @Accept json
// @Produce json
@@ -666,9 +615,6 @@ func (h *TenantHandler) GetTenantKV(c *gin.Context) {
key := secutils.SanitizeForLog(c.Param("key"))
switch key {
case "agent-config":
h.GetTenantAgentConfig(c)
return
case "web-search-config":
h.GetTenantWebSearchConfig(c)
return
@@ -696,7 +642,7 @@ func (h *TenantHandler) GetTenantKV(c *gin.Context) {
// UpdateTenantKV godoc
// @Summary 更新租户KV配置
// @Description 更新租户级别的KV配置支持agent-config、web-search-config
// @Description 更新租户级别的KV配置支持web-search-config、parser-engine-config、storage-engine-config、chat-history-config、retrieval-config
// @Tags 租户管理
// @Accept json
// @Produce json
@@ -712,9 +658,6 @@ func (h *TenantHandler) UpdateTenantKV(c *gin.Context) {
key := secutils.SanitizeForLog(c.Param("key"))
switch key {
case "agent-config":
h.updateTenantAgentConfigInternal(c)
return
case "web-search-config":
h.updateTenantWebSearchConfigInternal(c)
return

View File

@@ -98,9 +98,6 @@ type Tenant struct {
StorageQuota int64 `yaml:"storage_quota" json:"storage_quota" gorm:"default:10737418240"`
// Storage used (Bytes)
StorageUsed int64 `yaml:"storage_used" json:"storage_used" gorm:"default:0"`
// Deprecated: AgentConfig is deprecated, use CustomAgent (builtin-smart-reasoning) config instead.
// This field is kept for backward compatibility and will be removed in future versions.
AgentConfig *AgentConfig `yaml:"agent_config" json:"agent_config" gorm:"type:jsonb"`
// Global Context configuration for this tenant (default for all sessions)
ContextConfig *ContextConfig `yaml:"context_config" json:"context_config" gorm:"type:jsonb"`
// Global WebSearch configuration for this tenant