diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index cbfff40e..082de4dc 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -315,27 +315,30 @@ func bindConfigDefaults(v *viper.Viper) { } func bindEnvironmentAliases(v *viper.Viper) { - _ = v.BindEnv("server.port", "PORT", "SERVER_PORT", "AGENT_DESK_SERVER_PORT") - _ = v.BindEnv("server.companyName", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME", "AGENT_DESK_SERVER_COMPANYNAME") - _ = v.BindEnv("server.companyLogoUrl", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL", "AGENT_DESK_SERVER_COMPANYLOGOURL") - _ = v.BindEnv("db.type", "DATABASE_TYPE", "DB_TYPE", "AGENT_DESK_DB_TYPE") - _ = v.BindEnv("db.dsn", "DATABASE_URL", "DB_DSN", "AGENT_DESK_DB_DSN") - _ = v.BindEnv("auth.passwordLoginEnabled", "PASSWORD_LOGIN_ENABLED", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED") - _ = v.BindEnv("auth.tokenTTLHours", "AUTH_TOKEN_TTL_HOURS", "AGENT_DESK_AUTH_TOKENTTLHOURS") - _ = v.BindEnv("customerSession.secret", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET", "AGENT_DESK_CUSTOMERSESSION_SECRET") - _ = v.BindEnv("storage.default", "STORAGE_DEFAULT", "STORAGE_TYPE", "AGENT_DESK_STORAGE_DEFAULT") - _ = v.BindEnv("storage.local.root", "STORAGE_LOCAL_ROOT", "AGENT_DESK_STORAGE_LOCAL_ROOT") - _ = v.BindEnv("storage.local.baseUrl", "STORAGE_LOCAL_BASE_URL", "AGENT_DESK_STORAGE_LOCAL_BASEURL") - _ = v.BindEnv("vectorDB.type", "VECTOR_DB_TYPE", "AGENT_DESK_VECTORDB_TYPE") - _ = v.BindEnv("vectorDB.qdrant.host", "QDRANT_HOST", "AGENT_DESK_VECTORDB_QDRANT_HOST") - _ = v.BindEnv("vectorDB.qdrant.grpcPort", "QDRANT_GRPC_PORT", "QDRANT_PORT", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT") - _ = v.BindEnv("vectorDB.qdrant.apiKey", "QDRANT_API_KEY", "AGENT_DESK_VECTORDB_QDRANT_APIKEY") - _ = v.BindEnv("oidc.enabled", "OIDC_ENABLED", "AGENT_DESK_OIDC_ENABLED") - _ = v.BindEnv("oidc.issuer", "OIDC_ISSUER", "AGENT_DESK_OIDC_ISSUER") - _ = v.BindEnv("oidc.clientId", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID", "AGENT_DESK_OIDC_CLIENTID") - _ = v.BindEnv("oidc.clientSecret", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET", "AGENT_DESK_OIDC_CLIENTSECRET") - _ = v.BindEnv("oidc.redirectUrl", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI", "AGENT_DESK_OIDC_REDIRECTURL") - _ = v.BindEnv("webhook.orgSyncSecret", "ORG_SYNC_SECRET", "WEBHOOK_SECRET", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET") + // Prefixed AGENT_DESK_* aliases are listed first so that ambient legacy + // variables (PORT, DATABASE_URL, ...) cannot silently override the + // documented configuration. + _ = v.BindEnv("server.port", "AGENT_DESK_SERVER_PORT", "PORT", "SERVER_PORT") + _ = v.BindEnv("server.companyName", "AGENT_DESK_SERVER_COMPANYNAME", "COMPANY_NAME", "NEXT_PUBLIC_COMPANY_NAME", "BRAND_NAME", "BRAND_COMPANY_NAME") + _ = v.BindEnv("server.companyLogoUrl", "AGENT_DESK_SERVER_COMPANYLOGOURL", "COMPANY_LOGO_URL", "NEXT_PUBLIC_COMPANY_LOGO_URL", "BRAND_LOGO_URL") + _ = v.BindEnv("db.type", "AGENT_DESK_DB_TYPE", "DATABASE_TYPE", "DB_TYPE") + _ = v.BindEnv("db.dsn", "AGENT_DESK_DB_DSN", "DATABASE_URL", "DB_DSN") + _ = v.BindEnv("auth.passwordLoginEnabled", "AGENT_DESK_AUTH_PASSWORDLOGINENABLED", "PASSWORD_LOGIN_ENABLED") + _ = v.BindEnv("auth.tokenTTLHours", "AGENT_DESK_AUTH_TOKENTTLHOURS", "AUTH_TOKEN_TTL_HOURS") + _ = v.BindEnv("customerSession.secret", "AGENT_DESK_CUSTOMERSESSION_SECRET", "CUSTOMER_SESSION_SECRET", "SESSION_SECRET", "JWT_SECRET") + _ = v.BindEnv("storage.default", "AGENT_DESK_STORAGE_DEFAULT", "STORAGE_DEFAULT", "STORAGE_TYPE") + _ = v.BindEnv("storage.local.root", "AGENT_DESK_STORAGE_LOCAL_ROOT", "STORAGE_LOCAL_ROOT") + _ = v.BindEnv("storage.local.baseUrl", "AGENT_DESK_STORAGE_LOCAL_BASEURL", "STORAGE_LOCAL_BASE_URL") + _ = v.BindEnv("vectorDB.type", "AGENT_DESK_VECTORDB_TYPE", "VECTOR_DB_TYPE") + _ = v.BindEnv("vectorDB.qdrant.host", "AGENT_DESK_VECTORDB_QDRANT_HOST", "QDRANT_HOST") + _ = v.BindEnv("vectorDB.qdrant.grpcPort", "AGENT_DESK_VECTORDB_QDRANT_GRPCPORT", "QDRANT_GRPC_PORT", "QDRANT_PORT") + _ = v.BindEnv("vectorDB.qdrant.apiKey", "AGENT_DESK_VECTORDB_QDRANT_APIKEY", "QDRANT_API_KEY") + _ = v.BindEnv("oidc.enabled", "AGENT_DESK_OIDC_ENABLED", "OIDC_ENABLED") + _ = v.BindEnv("oidc.issuer", "AGENT_DESK_OIDC_ISSUER", "OIDC_ISSUER") + _ = v.BindEnv("oidc.clientId", "AGENT_DESK_OIDC_CLIENTID", "OIDC_CLIENT_ID", "CUSTOM_OAUTH_CLIENT_ID") + _ = v.BindEnv("oidc.clientSecret", "AGENT_DESK_OIDC_CLIENTSECRET", "OIDC_CLIENT_SECRET", "CUSTOM_OAUTH_CLIENT_SECRET") + _ = v.BindEnv("oidc.redirectUrl", "AGENT_DESK_OIDC_REDIRECTURL", "OIDC_REDIRECT_URL", "CUSTOM_OAUTH_REDIRECT_URI") + _ = v.BindEnv("webhook.orgSyncSecret", "AGENT_DESK_WEBHOOK_ORGSYNCSECRET", "ORG_SYNC_SECRET", "WEBHOOK_SECRET") } func normalizeLoadedConfig(cfg *Config) { diff --git a/internal/services/channel_message_outbox_service.go b/internal/services/channel_message_outbox_service.go index 2660a8bc..12014241 100644 --- a/internal/services/channel_message_outbox_service.go +++ b/internal/services/channel_message_outbox_service.go @@ -254,12 +254,18 @@ func (s *channelMessageOutboxService) ListPending(channelType string, limit int) if limit <= 0 { limit = 20 } + now := time.Now() cnd := sqls.NewCnd(). Eq("channel_type", strings.TrimSpace(channelType)). In("send_status", []string{ string(enums.ChannelMessageOutboxStatusPending), string(enums.ChannelMessageOutboxStatusFailed), }). + // Only rows whose backoff has elapsed are eligible; ordering by + // next_retry_at keeps a backlog of not-yet-due retries from starving + // newer pending sends. + Lte("next_retry_at", now). + Asc("next_retry_at"). Asc("id"). Limit(limit) return s.Find(cnd) diff --git a/internal/services/role_service.go b/internal/services/role_service.go index cf1046b8..6003f447 100644 --- a/internal/services/role_service.go +++ b/internal/services/role_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" @@ -172,6 +173,9 @@ func (s *roleService) AssignPermissions(roleID int64, permissionIDs []int64, ope if role == nil { return errorsx.InvalidParamI18n("error.e0305") } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } return s.replaceRolePermissions(roleID, permissionIDs, operator) } diff --git a/internal/services/user_service.go b/internal/services/user_service.go index c0c2f25d..67610673 100644 --- a/internal/services/user_service.go +++ b/internal/services/user_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" @@ -262,6 +263,9 @@ func (s *userService) replaceUserRolesDB(db *gorm.DB, userID int64, roleIDs []in if role.Status != enums.StatusOk { return errorsx.InvalidParamI18n("error.e0291") } + if role.IsSystem && (operator == nil || !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin))) { + return errorsx.ForbiddenI18n("error.e0293") + } relation := &models.UserRole{ UserID: userID, RoleID: roleID, @@ -279,6 +283,18 @@ func (s *userService) changePassword(userID int64, password string, operator *dt if user == nil || user.DeletedAt != nil { return errorsx.InvalidParamI18n("error.e0255") } + if operator != nil && operator.UserID != userID && !slices.Contains(operator.Roles, string(constants.RoleCodeSuperAdmin)) { + var superAdminCount int64 + if err := sqls.DB().Model(&models.UserRole{}). + Joins("JOIN t_role ON t_role.id = t_user_role.role_id"). + Where("t_user_role.user_id = ? AND t_role.code = ?", userID, string(constants.RoleCodeSuperAdmin)). + Count(&superAdminCount).Error; err != nil { + return err + } + if superAdminCount > 0 { + return errorsx.ForbiddenI18n("error.e0293") + } + } if strings.TrimSpace(password) == "" { return errorsx.InvalidParamI18n("error.e0220") } diff --git a/internal/services/ws_service.go b/internal/services/ws_service.go index 2f37acc2..53c07a8b 100644 --- a/internal/services/ws_service.go +++ b/internal/services/ws_service.go @@ -2,6 +2,7 @@ package services import ( "agent-desk/internal/models" + "agent-desk/internal/pkg/constants" "agent-desk/internal/pkg/dto" "agent-desk/internal/pkg/dto/response" "agent-desk/internal/pkg/enums" @@ -13,6 +14,7 @@ import ( "fmt" "log/slog" "net/http" + "slices" "strconv" "strings" "sync/atomic" @@ -626,7 +628,13 @@ func (s *wsService) canSubscribeConversation(session *ClientSession, conversatio return false } if session.Role == realtimeRoleAdmin { - return true + // Staff sessions must hold the same conversation-view permission the + // REST endpoints require; a bare admin-role websocket must not become + // a side channel around RequirePermission. + if session.Principal != nil && slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) { + return true + } + return false } conversation := ConversationService.Get(conversationID) if conversation == nil {