fix: harden identity, live access, drafts and setup (review only)

Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
This commit is contained in:
2026-10-16 12:15:20 +00:00
parent e57610a27c
commit 63e0457886
86 changed files with 5174 additions and 1929 deletions

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package bootstrap
import (
@@ -87,6 +89,7 @@ func registerApiWebhookRoutes(group *gin.RouterGroup) {
func registerApiCustomerRoutes(group *gin.RouterGroup, limits publicRateLimits) {
group.POST("/session_exchange", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_exchange)
group.POST("/session_refresh", middleware.RateLimit(limits.sessionExchange), api.CustomerPostSession_refresh)
}
func registerApiConversationRoutes(group *gin.RouterGroup) {

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package builders
import (
@@ -45,9 +47,7 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
ClosedBy: item.ClosedBy,
CloseReason: item.CloseReason,
}
if identity := services.ConversationService.GetConversationExternalIdentity(item); identity != nil {
ret.CustomerOnline = services.WsService.IsGuestOnline(identity.ExternalID)
}
ret.CustomerOnline = services.WsService.IsCustomerOnline(item.CustomerID, item.ChannelID)
if item.CurrentAssigneeID > 0 {
if user := services.UserService.Get(item.CurrentAssigneeID); user != nil {
ret.CurrentAssigneeName = user.Nickname

View File

@@ -1,7 +1,13 @@
// Modified in the independent hardening fork; see docs/hardening.
package api
import (
"time"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/services"
@@ -21,6 +27,7 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
IdentityHMAC: identity.Secret,
IdentityMaxAge: identity.MaxAge(),
ChannelID: channel.ChannelID,
RequireSignedIdentity: services.ChannelService.RequiresSignedIdentity(channel),
})
if err != nil {
httpx.WriteJSON(ctx, err)
@@ -33,3 +40,21 @@ func CustomerPostSession_exchange(ctx *gin.Context) {
}
httpx.WriteJSON(ctx, resp)
}
// Refresh requires possession of the still-valid customer credential; unlike
// unsigned session exchange it never creates or changes an anonymous identity.
func CustomerPostSession_refresh(ctx *gin.Context) {
channel := services.ChannelService.GetEnabledChannel(ctx)
if channel == nil { httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209")); return }
result, err := services.CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil { httpx.WriteJSON(ctx, err); return }
services.CustomerSessionService.SetRefreshHeaders(ctx, result)
source := "guest:"
if result.ExternalUser.ExternalSource == enums.ExternalSourceUser { source = "user:" }
httpx.WriteJSON(ctx, &response.CustomerSessionExchangeResponse{
CustomerSessionToken: result.Token,
ExpiresAt: result.ExpiresAt.UTC().Format(time.RFC3339),
IdentityKey: source + result.ExternalUser.ExternalID,
Customer: response.CustomerSessionCustomerResponse{ID: result.CustomerID, Name: result.ExternalUser.ExternalName},
})
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package models
import (
@@ -250,6 +252,10 @@ type Customer struct {
// CustomerIdentity 客户第三方身份映射表。
type CustomerIdentity struct {
// Empty scope is legacy/internal integration data; public sessions never fall back to it.
IdentityScope string `gorm:"type:varchar(80);not null;default:'';index:idx_customer_identity_scope"`
// Nullable for legacy rows; all new public scoped identities claim a unique digest.
ScopedKey *string `gorm:"type:varchar(64);uniqueIndex:uk_customer_scoped_key"`
ID int64 `gorm:"primaryKey;autoIncrement"`
CustomerID int64 `gorm:"type:bigint;not null;uniqueIndex:uk_customer_external"` // 为所属客户ID。
ExternalSource enums.ExternalSource `gorm:"type:varchar(30);uniqueIndex:uk_customer_external"` // 为外部身份来源

View File

@@ -1,6 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package dto
import "agent-desk/internal/pkg/enums"
import (
"agent-desk/internal/pkg/enums"
"time"
)
type AuthPrincipal struct {
UserID int64
@@ -11,16 +16,22 @@ type AuthPrincipal struct {
Status enums.Status
Roles []string
Permissions []string
// Session metadata stays internal and binds long-lived connections to the
// original login session and its absolute deadline.
LoginSessionID int64 `json:"-"`
ExpiresAt time.Time `json:"-"`
}
type WebChannelConfig struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ThemeColor string `json:"themeColor"`
Position string `json:"position"`
Width string `json:"width"`
UserTokenSecret string `json:"userTokenSecret,omitempty"`
RequireSignedIdentity bool `json:"requireSignedIdentity,omitempty"`
PromptStarters []string `json:"promptStarters,omitempty"`
}
type TelegramChannelConfig struct {

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package request
type SetupSimulateRouteRequest struct {
@@ -8,4 +9,6 @@ type SetupSimulateRouteRequest struct {
type SetupQuickFixRequest struct {
Action string `json:"action" form:"action"`
EntityID int64 `json:"entity_id" form:"entity_id"`
Apply bool `json:"apply" form:"apply"`
PreviewToken string `json:"preview_token" form:"preview_token"`
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package response
type SetupIssueItem struct {
@@ -92,7 +93,20 @@ type SetupSimulateRouteResponse struct {
CandidateOperators []SimulatedOperatorCandidate `json:"candidate_operators"`
}
type SetupQuickFixResponse struct {
AppliedActions []string `json:"applied_actions"`
Readiness SetupReadinessResponse `json:"readiness"`
type SetupQuickFixChange struct {
EntityType string `json:"entity_type"`
EntityID int64 `json:"entity_id"`
EntityName string `json:"entity_name"`
Operation string `json:"operation"`
Before map[string]any `json:"before"`
After map[string]any `json:"after"`
}
type SetupQuickFixResponse struct {
Applied bool `json:"applied"`
PreviewToken string `json:"preview_token"`
Changes []SetupQuickFixChange `json:"changes"`
WarningCodes []string `json:"warning_codes"`
AppliedActions []string `json:"applied_actions"`
Readiness *SetupReadinessResponse `json:"readiness,omitempty"`
}

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent not found."
error.e0003: "AI Agent not found or disabled."
@@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "The user's expected outcom
tool.graph.prepareTicketDraft.param.currentAttempt: "Current attempted troubleshooting steps. Optional."
error.setup.invalidQuickFixAction: "Unsupported setup quick-fix action: %v"
error.setup.authRequired: "Sign in to run setup diagnostics or quick fixes"
error.setup.previewRequired: "Review a setup preview and explicitly confirm its token before applying changes."
error.setup.previewChanged: "Configuration changed since the preview. Review a new preview before applying."
error.setup.targetRequired: "Select a specific existing record, or use its settings to choose the intended configuration."
error.setup.targetUnavailable: "The selected record is unavailable, deleted or ineligible. It was not restored."
error.setup.persistenceFailed: "Setup configuration could not be read or saved. No incomplete repair was committed."
error.setup.bypassUnsupported: "Bypassing AI is not supported by this diagnostic. Run a human-only route check or test AI separately."
error.channel.configChanged: "The channel configuration changed. Reload it and review your changes before saving again."
error.channel.configUpdateFailed: "The channel configuration could not be saved. Please try again."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "ИИ-агент не найден"
error.e0002: "ИИ-агент не найден."
error.e0003: "ИИ-агент не найден или отключён."
@@ -566,4 +567,12 @@ tool.graph.prepareTicketDraft.param.impact: "Область воздействи
tool.graph.prepareTicketDraft.param.expectedOutcome: "Ожидаемый результат или запрос пользователя."
tool.graph.prepareTicketDraft.param.currentAttempt: "Предпринятые шаги по устранению. Необязательно."
error.setup.invalidQuickFixAction: "Неподдерживаемое действие быстрой настройки: %v"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.authRequired: "Войдите в систему для запуска диагностики или быстрой настройки"
error.setup.previewRequired: "Перед применением изменений просмотрите план и явно подтвердите его токен."
error.setup.previewChanged: "После предварительного просмотра настройки изменились. Просмотрите новый план."
error.setup.targetRequired: "Выберите конкретную запись или задайте нужную конфигурацию в её настройках."
error.setup.targetUnavailable: "Выбранная запись недоступна, удалена или не подходит. Она не была восстановлена."
error.setup.persistenceFailed: "Не удалось прочитать или сохранить настройки. Неполное исправление не было сохранено."
error.setup.bypassUnsupported: "Эта диагностика не поддерживает обход ИИ. Проверьте маршрут только с оператором или протестируйте ИИ отдельно."
error.channel.configChanged: "Настройки канала изменились. Перезагрузите их и проверьте изменения перед повторным сохранением."
error.channel.configUpdateFailed: "Не удалось сохранить настройки канала. Попробуйте ещё раз."

View File

@@ -1,3 +1,4 @@
# Modified in the independent hardening fork; upstream notices retained.
error.e0001: "AI Agent not found"
error.e0002: "AI Agent 不存在"
error.e0003: "AI Agent 不存在或已停用"
@@ -567,3 +568,12 @@ tool.graph.prepareTicketDraft.param.expectedOutcome: "用户期望的处理结
tool.graph.prepareTicketDraft.param.currentAttempt: "当前已尝试过的处理步骤,可选。"
error.setup.invalidQuickFixAction: "不支持的一键修复操作:%v"
error.setup.authRequired: "请先登录以执行配置诊断或一键修复"
error.setup.previewRequired: "应用更改前,请先预览设置并明确确认预览令牌。"
error.setup.previewChanged: "预览后配置已更改。请重新预览后再应用。"
error.setup.targetRequired: "请选择具体记录,或在对应设置中选择所需配置。"
error.setup.targetUnavailable: "所选记录不可用、已删除或不符合条件,未进行恢复。"
error.setup.persistenceFailed: "无法读取或保存设置配置,未提交不完整的修复。"
error.setup.bypassUnsupported: "此诊断不支持绕过 AI。请检查仅人工路由,或单独测试 AI。"
error.channel.configChanged: "渠道配置已更改。请重新加载并检查更改后再保存。"
error.channel.configUpdateFailed: "无法保存渠道配置,请重试。"

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -269,3 +271,25 @@ func flipLastChar(signature string) string {
func itoa(value int64) string {
return strconv.FormatInt(value, 10)
}
func TestRequireSignedIdentityRejectsUnsignedAndInvalidSignatures(t *testing.T) {
for _, signature := range []string{"", "invalid"} {
ctx, _ := newIdentityContext(map[string]string{"X-External-Id": testUserID, "X-External-Id-Signature": signature})
if _, err := GetExternalUser(ctx, Secrets{ChannelID: testChannelID, IdentityHMAC: testSecret, RequireSignedIdentity: true}); err == nil { t.Fatal("signed-only policy accepted an unsigned or invalid identity") }
}
ctx, _ := newIdentityContext(signedIdentityHeaders(testSecret,testChannelID,testUserID,time.Now()))
user, err := GetExternalUser(ctx, Secrets{ChannelID:testChannelID,IdentityHMAC:testSecret,RequireSignedIdentity:true})
if err != nil || user.IdentityScope != IdentityScope(testChannelID,"hmac") { t.Fatal("valid signed identity did not retain its HMAC scope") }
}
func TestIdentityScopeSeparatesIssuersAndChannels(t *testing.T) {
seen := map[string]bool{}
for _, channel := range []string{"channel-one","channel-two"} {
for _, issuer := range []string{"guest","hmac","jwt"} {
scope := IdentityScope(channel,issuer)
if scope == "" || seen[scope] { t.Fatal("identity scopes collided") }
seen[scope] = true
}
}
if IdentityScope("", "hmac") != "" || IdentityScope("channel", "unknown") != "" { t.Fatal("invalid scope inputs accepted") }
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package openidentity
import (
@@ -24,6 +26,9 @@ type ExternalUser struct {
ExternalSource enums.ExternalSource `json:"externalSource"`
ExternalID string `json:"externalId"`
ExternalName string `json:"externalName"`
// IdentityScope and ChannelID are server-established authorization context, never request fields.
IdentityScope string `json:"-"`
ChannelID int64 `json:"-"`
}
type UserTokenClaims struct {
@@ -47,6 +52,7 @@ type Secrets struct {
// ChannelID is bound into the signed payload so a signature obtained for one
// channel cannot be replayed on another.
ChannelID string
RequireSignedIdentity bool
}
// DefaultIdentityMaxAge is how long a signed visitor identity stays acceptable
@@ -65,9 +71,8 @@ const identityFutureSkewTolerance = 5 * time.Minute
//
// issuedAt is unix seconds. Lower-case hex output. Three details are deliberate:
//
// - The channel id is included because CustomerIdentity is keyed by
// (source, externalId) globally, so without it a signature minted for one
// channel would assert the same identity on every other channel.
// - The channel id is included so signatures cannot be replayed on a
// different channel, even when a deployment shares an HMAC signing key.
// - The timestamp is inside the signed bytes even though it also travels as its
// own field, so it cannot be re-dated without breaking the signature. This
// bounds how long a leaked pair stays usable; it is not a defence against key
@@ -126,6 +131,7 @@ func GetExternalUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error) {
ExternalSource: enums.ExternalSourceUser,
ExternalID: claims.UserID,
ExternalName: claims.Name,
IdentityScope: IdentityScope(secrets.ChannelID, "jwt"),
}, nil
}
return getExternalIDUser(ctx, secrets)
@@ -201,13 +207,18 @@ func getExternalIDUser(ctx *gin.Context, secrets Secrets) (*ExternalUser, error)
ExternalSource: enums.ExternalSourceUser,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "hmac"),
}, nil
}
if secrets.RequireSignedIdentity {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &ExternalUser{
ExternalSource: enums.ExternalSourceGuest,
ExternalID: externalID,
ExternalName: externalName,
IdentityScope: IdentityScope(secrets.ChannelID, "guest"),
}, nil
}
@@ -267,3 +278,24 @@ func decodeExternalDisplayName(s string) string {
}
return strings.TrimSpace(dec)
}
// IdentityScope separates independent identity issuers, trust levels and channels.
// It deliberately does not depend on a secret so ordinary key rotation preserves
// the issuer's history. Reassigning a channel to a different issuer requires a new
// channel, not silently reusing that issuer's namespace.
func IdentityScope(channelCode, issuer string) string {
if strings.TrimSpace(channelCode) == "" { return "" }
switch issuer { case "jwt", "hmac", "guest": default: return "" }
digest := sha256.Sum256([]byte(strings.TrimSpace(channelCode)))
return "v1:" + issuer + ":" + hex.EncodeToString(digest[:])
}
func ValidIdentityScope(channelCode string, external ExternalUser) bool {
if external.ExternalSource == enums.ExternalSourceGuest {
return external.IdentityScope != "" && external.IdentityScope == IdentityScope(channelCode, "guest")
}
if external.ExternalSource == enums.ExternalSourceUser {
return external.IdentityScope != "" && (external.IdentityScope == IdentityScope(channelCode, "jwt") || external.IdentityScope == IdentityScope(channelCode, "hmac"))
}
return false
}

View File

@@ -0,0 +1,68 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"time"
"gorm.io/gorm"
)
// These handwritten queries supplement the generated CRUD repositories.
func (r *roleRepository) FindEnabledByUser(db *gorm.DB, userID int64) ([]models.Role, error) {
roles := make([]models.Role, 0)
err := db.Table("t_role AS r").
Select("r.*").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
Order("r.sort_no ASC, r.id ASC").
Scan(&roles).Error
return roles, err
}
func (r *permissionRepository) FindEnabledCodesByUserRoles(db *gorm.DB, userID int64) ([]string, error) {
codes := make([]string, 0)
err := db.Table("t_permission AS p").
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
Joins("JOIN t_role AS r ON r.id = rp.role_id").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ? AND p.status = ?", userID, enums.StatusOk, enums.StatusOk).
Distinct().Pluck("p.code", &codes).Error
return codes, err
}
type UserPermissionOverride struct {
Code string
Effect int
}
func (r *userPermissionRepository) FindActiveEnabledOverrides(db *gorm.DB, userID int64, now time.Time) ([]UserPermissionOverride, error) {
overrides := make([]UserPermissionOverride, 0)
err := db.Table("t_user_permission AS up").
Select("p.code, up.effect").
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
Where("up.user_id = ? AND p.status = ?", userID, enums.StatusOk).
Where("up.expired_at IS NULL OR up.expired_at > ?", now).
Scan(&overrides).Error
return overrides, err
}
// GetAuthState deliberately excludes the bearer token; revalidation only needs
// the identity binding, revocation status, and absolute expiry.
func (r *loginSessionRepository) GetAuthState(db *gorm.DB, sessionID, userID int64) (*models.LoginSession, error) {
session := &models.LoginSession{}
if err := db.Select("id", "user_id", "expired_at", "revoked_at").
Take(session, "id = ? AND user_id = ?", sessionID, userID).Error; err != nil {
return nil, err
}
return session, nil
}
func (r *userRepository) GetAuthState(db *gorm.DB, userID int64) (*models.User, error) {
user := &models.User{}
if err := db.Select("id", "username", "nickname", "avatar", "user_type", "status", "deleted_at").
Take(user, "id = ?", userID).Error; err != nil {
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,20 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// UpdatesConfigIfUnchanged prevents a stale read/modify/write from replacing a
// concurrently tightened authentication policy or rotated channel secret. HEX is
// available in SQLite and MySQL and compares exact bytes even when MySQL's text
// column uses a case-insensitive or trailing-space-insensitive collation.
func (r *channelRepository) UpdatesConfigIfUnchanged(db *gorm.DB, expected *models.Channel, columns map[string]any) (bool, error) {
if expected == nil || expected.ID <= 0 { return false, nil }
result := db.Model(&models.Channel{}).
Where("id = ? AND status <> ? AND updated_at = ?", expected.ID, enums.StatusDeleted, expected.UpdatedAt).
Where("HEX(COALESCE(config_json, '')) = HEX(?)", expected.ConfigJSON).
Updates(columns)
return result.RowsAffected == 1, result.Error
}

View File

@@ -0,0 +1,17 @@
package repositories
import (
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// FindPendingDispatchBatch returns a bounded, checked page. A failed read must
// not be mistaken for an empty queue by setup or the periodic dispatcher.
func (r *conversationRepository) FindPendingDispatchBatch(db *gorm.DB, limit int) ([]models.Conversation, error) {
conversations := make([]models.Conversation, 0)
err := db.Where("status = ? AND current_assignee_id = ?", enums.IMConversationStatusPending, 0).
Order("id DESC").Limit(limit).Find(&conversations).Error
return conversations, err
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package repositories
import (
@@ -6,7 +8,6 @@ import (
"agent-desk/internal/pkg/httpx/params"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
@@ -36,14 +37,11 @@ func (r *customerIdentityRepository) Take(db *gorm.DB, where ...interface{}) *mo
return ret
}
// GetBy 按外部来源 + 外部用户标识查询身份映射(与 uk_customer_external 一致)。
// GetBy is the legacy/internal-integration lookup. Ambiguous mappings fail closed.
func (r *customerIdentityRepository) GetBy(db *gorm.DB, externalSource enums.ExternalSource, externalID string) *models.CustomerIdentity {
if strs.IsAnyBlank(string(externalSource), externalID) {
return nil
}
return r.FindOne(db, sqls.NewCnd().
Eq("external_source", externalSource).
Eq("external_id", externalID))
identity, err := r.GetUniqueBy(db, "", externalSource, externalID)
if err != nil { return nil }
return identity
}
func (r *customerIdentityRepository) FindByCustomerID(db *gorm.DB, customerID int64) []models.CustomerIdentity {

View File

@@ -0,0 +1,51 @@
package repositories
import (
"errors"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"gorm.io/gorm"
)
// ErrAmbiguousCustomerIdentity requires deliberate operator reconciliation. Never
// choose the first row: the historical unique index also includes customer_id.
var ErrAmbiguousCustomerIdentity = errors.New("ambiguous customer identity")
// GetUniqueBy includes disabled rows when detecting conflicts. A disabled mapping
// must not let a caller create a replacement customer and reacquire the identity.
func (r *customerIdentityRepository) GetUniqueBy(db *gorm.DB, scope string, source enums.ExternalSource, externalID string) (*models.CustomerIdentity, error) {
if strings.TrimSpace(string(source)) == "" || strings.TrimSpace(externalID) == "" {
return nil, errors.New("invalid customer identity")
}
var identities []models.CustomerIdentity
query := db.Where("identity_scope = ? AND external_source = ? AND external_id = ?", scope, source, strings.TrimSpace(externalID))
if key := ScopedCustomerIdentityKey(scope, source, externalID); key != nil {
// Digest comparison is independent of MySQL's external_id collation. The
// NULL branch also detects manually migrated/inconsistent scoped mappings.
query = db.Where("scoped_key = ? OR (scoped_key IS NULL AND identity_scope = ? AND external_source = ? AND external_id = ?)", *key, scope, source, strings.TrimSpace(externalID))
}
err := query.Limit(2).Find(&identities).Error
if err != nil { return nil, err }
if len(identities) > 1 { return nil, ErrAmbiguousCustomerIdentity }
if len(identities) == 0 { return nil, nil }
if identities[0].IdentityScope != scope || identities[0].ExternalSource != source || identities[0].ExternalID != strings.TrimSpace(externalID) { return nil, ErrAmbiguousCustomerIdentity }
if identities[0].Status != enums.StatusOk { return nil, errors.New("inactive customer identity") }
return &identities[0], nil
}
// ScopedCustomerIdentityKey gives new scoped registrations a database-enforced
// claim without imposing a new unique constraint on dirty legacy data. Both
// SQLite and MySQL permit multiple NULL values in a unique index. Encoding an
// array avoids delimiter collisions and hashing avoids collation ambiguity.
func ScopedCustomerIdentityKey(scope string, source enums.ExternalSource, externalID string) *string {
if scope == "" { return nil }
encoded, _ := json.Marshal([]string{scope, string(source), strings.TrimSpace(externalID)})
digest := sha256.Sum256(encoded)
key := hex.EncodeToString(digest[:])
return &key
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -50,21 +52,27 @@ func (s *authService) GetAuthPrincipal(ctx *gin.Context) *dto.AuthPrincipal {
return nil
}
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, roles, permissions []string) *dto.AuthPrincipal {
principal := &dto.AuthPrincipal{
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Avatar: user.Avatar,
UserType: user.UserType,
Status: user.Status,
Roles: roles,
Permissions: permissions,
}
func (s *authService) setAuthPrincipal(ctx *gin.Context, user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal {
principal := buildAuthPrincipal(user, session, roles, permissions)
ctx.Set(authPrincipalContextKey, principal)
return principal
}
func buildAuthPrincipal(user *models.User, session *models.LoginSession, roles, permissions []string) *dto.AuthPrincipal {
return &dto.AuthPrincipal{
UserID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Avatar: user.Avatar,
UserType: user.UserType,
Status: user.Status,
Roles: slices.Clone(roles),
Permissions: slices.Clone(permissions),
LoginSessionID: session.ID,
ExpiresAt: session.ExpiredAt,
}
}
func (s *authService) RequirePermission(ctx *gin.Context, permission constants.Permission) (principal *dto.AuthPrincipal, err error) {
if principal = s.GetAuthPrincipal(ctx); principal == nil {
if principal, err = s.Authenticate(ctx); err != nil {
@@ -96,7 +104,7 @@ func (s *authService) Login(req request.LoginRequest, authCfg config.AuthConfig,
}
user := UserService.GetByUsername(username)
if user == nil || user.Status != enums.StatusOk {
if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
_ = s.createLoginCredentialLog(principal, 0, false, clientIP, userAgent, "user not found")
return nil, errorsx.InvalidAccountI18n("error.e0260")
}
@@ -165,7 +173,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
}
user := UserService.Get(session.UserID)
if user == nil || user.Status != enums.StatusOk {
if user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
return nil, errorsx.UnauthorizedI18n("error.e0256")
}
@@ -173,7 +181,7 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
if err != nil {
return nil, err
}
principal := s.setAuthPrincipal(ctx, user, roles, permissions)
principal := s.setAuthPrincipal(ctx, user, session, roles, permissions)
now := time.Now()
_ = LoginSessionService.Updates(session.ID, map[string]any{
@@ -184,6 +192,39 @@ func (s *authService) Authenticate(ctx *gin.Context) (*dto.AuthPrincipal, error)
return principal, nil
}
// RevalidatePrincipal refreshes a long-lived connection's authorization without
// keeping the bearer token. The original snapshot is never mutated and its
// absolute deadline cannot be extended by revalidation.
func (s *authService) RevalidatePrincipal(principal *dto.AuthPrincipal) (*dto.AuthPrincipal, error) {
return s.revalidatePrincipal(sqls.DB(), principal, time.Now())
}
func (s *authService) revalidatePrincipal(db *gorm.DB, principal *dto.AuthPrincipal, now time.Time) (*dto.AuthPrincipal, error) {
if principal == nil || principal.UserID <= 0 || principal.LoginSessionID <= 0 || !now.Before(principal.ExpiresAt) {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
session, err := repositories.LoginSessionRepository.GetAuthState(db, principal.LoginSessionID, principal.UserID)
if err != nil || session == nil || session.RevokedAt != nil || !now.Before(session.ExpiredAt) {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
user, err := repositories.UserRepository.GetAuthState(db, principal.UserID)
if err != nil || user == nil || user.DeletedAt != nil || user.Status != enums.StatusOk {
return nil, errorsx.UnauthorizedI18n("error.e0256")
}
roles, permissions, err := s.loadUserAuthScope(db, user.ID)
if err != nil {
// Database failures must close the connection, not retain its old scope or
// expose persistence details through a transport error.
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
fresh := buildAuthPrincipal(user, session, roles, permissions)
if principal.ExpiresAt.Before(fresh.ExpiresAt) {
fresh.ExpiresAt = principal.ExpiresAt
}
return fresh, nil
}
func (s *authService) HasPermission(ctx *gin.Context, permissionCode string) bool {
principal := s.GetAuthPrincipal(ctx)
if principal == nil {
@@ -341,7 +382,7 @@ func (s *authService) validateSessionToken(token string) (*models.LoginSession,
if session.RevokedAt != nil {
return nil, errorsx.InvalidTokenI18n("error.e0267")
}
if time.Now().After(session.ExpiredAt) {
if !time.Now().Before(session.ExpiredAt) {
return nil, errorsx.InvalidTokenI18n("error.e0268")
}
return session, nil
@@ -372,49 +413,16 @@ func (s *authService) loadUserRoleCodes(tx *gorm.DB, userID int64) ([]string, er
}
func (s *authService) loadUserRoles(tx *gorm.DB, userID int64) ([]models.Role, error) {
roles := make([]models.Role, 0)
if err := tx.
Table("t_role AS r").
Select("r.*").
Joins("JOIN t_user_role AS ur ON ur.role_id = r.id").
Where("ur.user_id = ? AND r.status = ?", userID, enums.StatusOk).
Order("r.sort_no ASC, r.id ASC").
Scan(&roles).Error; err != nil {
return nil, err
}
return roles, nil
return repositories.RoleRepository.FindEnabledByUser(tx, userID)
}
func (s *authService) loadUserPermissionCodes(tx *gorm.DB, userID int64) ([]string, error) {
permissionRows := make([]struct {
Code string
}, 0)
db := tx.Table("t_permission AS p").
Select("DISTINCT p.code").
Joins("JOIN t_role_permission AS rp ON rp.permission_id = p.id").
Joins("JOIN t_user_role AS ur ON ur.role_id = rp.role_id").
Where("ur.user_id = ?", userID).
Where("p.status = ?", enums.StatusOk)
if err := db.Scan(&permissionRows).Error; err != nil {
permissionCodes, err := repositories.PermissionRepository.FindEnabledCodesByUserRoles(tx, userID)
if err != nil {
return nil, err
}
permissionCodes := make([]string, 0, len(permissionRows))
for _, permission := range permissionRows {
permissionCodes = append(permissionCodes, permission.Code)
}
overrideRows := make([]struct {
Code string
Effect int
}, 0)
if err := tx.
Table("t_user_permission AS up").
Select("p.code, up.effect").
Joins("JOIN t_permission AS p ON p.id = up.permission_id").
Where("up.user_id = ? AND (up.expired_at IS NULL OR up.expired_at > ?)", userID, time.Now()).
Scan(&overrideRows).Error; err != nil {
overrideRows, err := repositories.UserPermissionRepository.FindActiveEnabledOverrides(tx, userID, time.Now())
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,246 @@
package services
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"slices"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
type authSessionFixture struct {
db *gorm.DB
now time.Time
user models.User
session models.LoginSession
role models.Role
permission models.Permission
principal *dto.AuthPrincipal
}
func newAuthSessionFixture(t *testing.T) *authSessionFixture {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "auth.db")), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open isolated auth database: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&models.User{}, &models.LoginSession{}, &models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}); err != nil {
t.Fatalf("migrate isolated auth database: %v", err)
}
f := &authSessionFixture{db: db, now: time.Now().UTC().Truncate(time.Microsecond)}
f.user = models.User{Username: "staff", UserType: enums.UserTypeEmployee, Status: enums.StatusOk}
f.role = models.Role{Code: "support", Status: enums.StatusOk}
f.permission = models.Permission{Code: "conversation.read", Status: enums.StatusOk}
for _, item := range []any{&f.user, &f.role, &f.permission} {
if err := db.Create(item).Error; err != nil {
t.Fatalf("seed auth fixture: %v", err)
}
}
f.session = models.LoginSession{UserID: f.user.ID, Token: "ak_test_only_session", ExpiredAt: f.now.Add(time.Hour)}
for _, item := range []any{
&f.session,
&models.UserRole{UserID: f.user.ID, RoleID: f.role.ID},
&models.RolePermission{RoleID: f.role.ID, PermissionID: f.permission.ID},
} {
if err := db.Create(item).Error; err != nil {
t.Fatalf("seed auth fixture: %v", err)
}
}
f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, []string{f.permission.Code})
return f
}
func TestAuthScopeExcludesDisabledRolesAndPermissionOverrides(t *testing.T) {
f := newAuthSessionFixture(t)
disabledRole := models.Role{Code: "disabled", Status: enums.StatusDisabled}
if err := f.db.Create(&disabledRole).Error; err != nil {
t.Fatal(err)
}
if err := f.db.Create(&models.UserRole{UserID: f.user.ID, RoleID: disabledRole.ID}).Error; err != nil {
t.Fatal(err)
}
permissions := []models.Permission{
{Code: "disabled-role-only", Status: enums.StatusOk},
{Code: "disabled-permission", Status: enums.StatusDisabled},
{Code: "direct-allow", Status: enums.StatusOk},
{Code: "expired-allow", Status: enums.StatusOk},
}
if err := f.db.Create(&permissions).Error; err != nil {
t.Fatal(err)
}
for _, item := range []any{
&models.RolePermission{RoleID: disabledRole.ID, PermissionID: permissions[0].ID},
&models.RolePermission{RoleID: f.role.ID, PermissionID: permissions[1].ID},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[1].ID, Effect: 1},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[2].ID, Effect: 1},
&models.UserPermission{UserID: f.user.ID, PermissionID: permissions[3].ID, Effect: 1, ExpiredAt: &f.now},
&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1},
} {
if err := f.db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
roles, codes, err := newAuthService().loadUserAuthScope(f.db, f.user.ID)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(roles, []string{"support"}) || !slices.Equal(codes, []string{"direct-allow"}) {
t.Fatalf("unexpected enabled authorization scope: roles=%v permissions=%v", roles, codes)
}
}
func TestRevalidatePrincipalRejectsRevokedExpiredOrUnboundIdentity(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
}{
{"nil principal", func(f *authSessionFixture) error { f.principal = nil; return nil }},
{"missing session binding", func(f *authSessionFixture) error { f.principal.LoginSessionID = 0; return nil }},
{"missing user binding", func(f *authSessionFixture) error { f.principal.UserID = 0; return nil }},
{"missing expiry", func(f *authSessionFixture) error { f.principal.ExpiresAt = time.Time{}; return nil }},
{"original deadline reached", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now; return nil }},
{"original deadline passed", func(f *authSessionFixture) error { f.principal.ExpiresAt = f.now.Add(-time.Second); return nil }},
{"revoked session", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", f.now).Error }},
{"session deadline reached", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now).Error }},
{"session deadline passed", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", f.now.Add(-time.Second)).Error }},
{"deleted session", func(f *authSessionFixture) error { return f.db.Delete(&f.session).Error }},
{"different session owner", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("user_id", f.user.ID + 1).Error }},
{"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }},
{"soft-deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", f.now).Error }},
{"deleted user", func(f *authSessionFixture) error { return f.db.Delete(&f.user).Error }},
{"failed session query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.LoginSession{}) }},
{"failed scope query", func(f *authSessionFixture) error { return f.db.Migrator().DropTable(&models.Permission{}) }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAuthSessionFixture(t)
if err := tc.change(f); err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) {
t.Fatalf("expected fail-closed unauthorized result, got principal=%v err=%v", fresh != nil, err)
}
})
}
}
func TestRevalidatePrincipalRefreshesScopeWithoutMutatingSnapshot(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
wantRoles []string
}{
{"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }, nil},
{"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }, nil},
{"removed role permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }, []string{"support"}},
{"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }, []string{"support"}},
{"direct deny", func(f *authSessionFixture) error { return f.db.Create(&models.UserPermission{UserID: f.user.ID, PermissionID: f.permission.ID, Effect: -1}).Error }, []string{"support"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newAuthSessionFixture(t)
if err := tc.change(f); err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if err != nil {
t.Fatal(err)
}
if fresh == f.principal || len(fresh.Permissions) != 0 || !slices.Equal(fresh.Roles, tc.wantRoles) {
t.Fatalf("expected a fresh reduced scope: roles=%v permissions=%v", fresh.Roles, fresh.Permissions)
}
if !slices.Equal(f.principal.Roles, []string{"support"}) || !slices.Equal(f.principal.Permissions, []string{"conversation.read"}) {
t.Fatal("revalidation mutated the original snapshot")
}
})
}
}
func TestRevalidatePrincipalCannotExtendAbsoluteExpiry(t *testing.T) {
for _, delta := range []time.Duration{-time.Minute, time.Minute} {
t.Run(delta.String(), func(t *testing.T) {
f := newAuthSessionFixture(t)
storedExpiry := f.session.ExpiredAt.Add(delta)
if err := f.db.Model(&f.session).Update("expired_at", storedExpiry).Error; err != nil {
t.Fatal(err)
}
fresh, err := newAuthService().revalidatePrincipal(f.db, f.principal, f.now)
if err != nil {
t.Fatal(err)
}
want := f.principal.ExpiresAt
if storedExpiry.Before(want) {
want = storedExpiry
}
if !fresh.ExpiresAt.Equal(want) {
t.Fatal("revalidation did not preserve the earliest absolute deadline")
}
fresh.Roles[0] = "changed"
fresh.Permissions[0] = "changed"
if f.principal.Roles[0] != "support" || f.principal.Permissions[0] != "conversation.read" {
t.Fatal("fresh authorization slices alias the original snapshot")
}
})
}
}
func TestAuthenticateBindsPrincipalToSessionWithoutExposingMetadata(t *testing.T) {
f := newAuthSessionFixture(t)
previousDB := sqls.DB()
sqls.SetDB(f.db)
t.Cleanup(func() { sqls.SetDB(previousDB) })
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/", nil)
ctx.Request.Header.Set("Authorization", "Bearer " + f.session.Token)
principal, err := newAuthService().Authenticate(ctx)
if err != nil {
t.Fatal(err)
}
if principal.LoginSessionID != f.session.ID || !principal.ExpiresAt.Equal(f.session.ExpiredAt) {
t.Fatal("authenticated principal is not bound to its original session")
}
if _, err := newAuthService().RevalidatePrincipal(principal); err != nil {
t.Fatalf("valid session failed public revalidation API: %v", err)
}
encoded, err := json.Marshal(principal)
if err != nil {
t.Fatal(err)
}
var publicFields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &publicFields); err != nil {
t.Fatal(err)
}
for _, name := range []string{"LoginSessionID", "ExpiresAt", "Token"} {
if _, ok := publicFields[name]; ok {
t.Fatalf("internal auth metadata leaked into JSON: %s", name)
}
}
if err := newAuthService().Logout("Bearer " + f.session.Token); err != nil {
t.Fatal(err)
}
if fresh, err := newAuthService().RevalidatePrincipal(principal); fresh != nil || !hasCode(err, errorsx.CodeAuthUnauthorized) {
t.Fatalf("logout left the authenticated snapshot valid: principal=%v err=%v", fresh != nil, err)
}
}

View File

@@ -0,0 +1,78 @@
package services
import (
"sync/atomic"
"encoding/json"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestChannelConfigCompareAndSwapRejectsStalePolicyAndSecretWrites(t *testing.T) {
for _, action:=range []string{"editor", "secret-reset"} {
t.Run(action,func(t *testing.T) {
previousDB:=sqls.DB()
db:=setupChannelServiceTestDB(t)
t.Cleanup(func(){sqls.SetDB(previousDB)})
agent:=createChannelServiceTestAgent(t,db,1001)
channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Policy channel",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":false,"userTokenSecret":"test-old-secret"}`},channelServiceTestOperator())
if err!=nil {t.Fatal(err)}
var injected atomic.Bool
callbackName:="test:concurrent_channel_policy"
// Commit another writer after the stale service read, just before its SQL.
// Preserve updated_at deliberately: bytewise JSON CAS must also catch this.
if err:=db.Callback().Update().Before("gorm:update").Register(callbackName,func(tx *gorm.DB){
if tx.Statement.Schema==nil || tx.Statement.Schema.Table!="t_channel" || !injected.CompareAndSwap(false,true){return}
cfg,parseErr:=ChannelService.ParseWebChannelConfig(channel.ConfigJSON)
if parseErr!=nil {tx.AddError(parseErr);return}
cfg.RequireSignedIdentity=true
cfg.UserTokenSecret="test-new-secret"
encoded,encodeErr:=json.Marshal(cfg)
if encodeErr!=nil {tx.AddError(encodeErr);return}
if writeErr:=db.Session(&gorm.Session{NewDB:true}).Model(&models.Channel{}).Where("id = ?",channel.ID).Updates(map[string]any{"config_json":string(encoded),"updated_at":channel.UpdatedAt}).Error;writeErr!=nil{tx.AddError(writeErr)}
});err!=nil{t.Fatal(err)}
t.Cleanup(func(){_ = db.Callback().Update().Remove(callbackName)})
switch action {
case "editor": err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Old editor title",Status:int(enums.StatusOk),ConfigJSON:`{"title":"old editor"}`}},channelServiceTestOperator())
case "secret-reset": var secret string;secret,err=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator());if secret!=""{t.Fatal("failed reset returned an uncommitted secret")}
}
if !injected.Load() || err==nil {t.Fatal("stale writer was not rejected")}
current:=ChannelService.Get(channel.ID)
cfg,parseErr:=ChannelService.ParseWebChannelConfig(current.ConfigJSON)
if parseErr!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!="test-new-secret" {t.Fatal("stale writer replaced the concurrent policy/secret")}
})
}
}
func TestChannelConfigCompareAndSwapUsesExactBytes(t *testing.T) {
db,channel:=setupIdentitySecurityDB(t)
channel.ConfigJSON=`{"userTokenSecret":"CaseSensitive"}`
if err:=db.Model(channel).Updates(map[string]any{"config_json":channel.ConfigJSON,"updated_at":time.Now()}).Error;err!=nil{t.Fatal(err)}
expected:=*channel
if err:=db.Model(channel).UpdateColumn("config_json",`{"userTokenSecret":"casesensitive"}`).Error;err!=nil{t.Fatal(err)}
updated,err:=repositories.ChannelRepository.UpdatesConfigIfUnchanged(db,&expected,map[string]any{"config_json":`{"requireSignedIdentity":false}`})
if err!=nil{t.Fatal(err)}
if updated{t.Fatal("case-sensitive secret change was not treated as a conflict")}
}
func TestChannelConfigCompareAndSwapAllowsCurrentEditorAndReset(t *testing.T) {
previousDB:=sqls.DB()
db:=setupChannelServiceTestDB(t)
t.Cleanup(func(){sqls.SetDB(previousDB)})
agent:=createChannelServiceTestAgent(t,db,1001)
channel,err:=ChannelService.CreateChannel(request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy",Status:int(enums.StatusOk),ConfigJSON:`{"requireSignedIdentity":true}`},channelServiceTestOperator())
if err!=nil{t.Fatal(err)}
err=ChannelService.UpdateChannel(request.UpdateChannelRequest{ID:channel.ID,CreateChannelRequest:request.CreateChannelRequest{ChannelType:enums.ChannelTypeWeb,AIAgentID:agent.ID,Name:"Current policy edited",Status:int(enums.StatusOk),ConfigJSON:`{"title":"Updated title"}`}},channelServiceTestOperator())
if err!=nil{t.Fatal(err)}
secret,err:=ChannelService.ResetUserTokenSecret(channel.ID,channelServiceTestOperator())
if err!=nil || secret==""{t.Fatal("current secret reset failed")}
cfg,err:=ChannelService.ParseWebChannelConfig(ChannelService.Get(channel.ID).ConfigJSON)
if err!=nil || !cfg.RequireSignedIdentity || cfg.UserTokenSecret!=secret{t.Fatal("current update/reset lost policy or returned unsaved secret")}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -98,6 +100,13 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if current == nil || current.Status == enums.StatusDeleted {
return errorsx.InvalidParamI18n("error.e0208")
}
if current.ChannelType == enums.ChannelTypeWeb && strings.TrimSpace(req.ChannelType) == enums.ChannelTypeWeb {
// Older dashboard clients do not expose this policy yet. Omitting the field
// during an unrelated edit must not silently turn authenticated-only off.
merged, err := s.preserveWebIdentityPolicy(req.ConfigJSON, current.ConfigJSON)
if err != nil { return err }
req.ConfigJSON = merged
}
item, err := s.buildChannelModel(req.ID, req.CreateChannelRequest)
if err != nil {
return err
@@ -118,7 +127,7 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent
}
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns)
return s.updateConfigIfUnchanged(current, columns)
}
// RollbackChannelAIAgentRollout restores the last channel-level rollout value
@@ -196,6 +205,11 @@ func (s *channelService) ParseWebChannelConfig(raw string) (*dto.WebChannelConfi
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
return nil, err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &fields); err != nil { return nil, err }
if policy, ok := fields["requireSignedIdentity"]; ok && string(policy) != "true" && string(policy) != "false" {
return nil, errorsx.InvalidParamI18n("error.e0060")
}
}
cfg.Title = strings.TrimSpace(cfg.Title)
if cfg.Title == "" {
@@ -459,7 +473,7 @@ func (s *channelService) ResetUserTokenSecret(channelID int64, operator *dto.Aut
}
configJSON = string(raw)
}
if err := repositories.ChannelRepository.Updates(sqls.DB(), channelID, map[string]any{
if err := s.updateConfigIfUnchanged(channel, map[string]any{
"config_json": configJSON,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
@@ -683,3 +697,36 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
Remark: strings.TrimSpace(req.Remark),
}, nil
}
// RequiresSignedIdentity enforces the channel policy on the server, not only in
// the embedding SDK. Invalid web configuration fails closed.
func (s *channelService) RequiresSignedIdentity(channel *models.Channel) bool {
if channel == nil { return true }
if channel.ChannelType != enums.ChannelTypeWeb { return false }
cfg, err := s.ParseWebChannelConfig(channel.ConfigJSON)
return err != nil || cfg.RequireSignedIdentity
}
func (s *channelService) preserveWebIdentityPolicy(raw, previousRaw string) (string, error) {
fields := map[string]json.RawMessage{}
if strings.TrimSpace(raw) != "" {
if err := json.Unmarshal([]byte(raw), &fields); err != nil { return "", errorsx.InvalidParamI18n("error.e0060") }
}
if _, present := fields["requireSignedIdentity"]; present { return raw, nil }
previous, err := s.ParseWebChannelConfig(previousRaw)
if err != nil { return "", errorsx.InvalidParamI18n("error.e0060") }
if !previous.RequireSignedIdentity { return raw, nil }
if fields == nil { fields = map[string]json.RawMessage{} }
fields["requireSignedIdentity"] = json.RawMessage("true")
encoded, err := json.Marshal(fields)
return string(encoded), err
}
// Reject rather than retry a stale complete config document: retrying could turn
// an old explicit field into authority to overwrite the new policy/secret.
func (s *channelService) updateConfigIfUnchanged(expected *models.Channel, columns map[string]any) error {
updated, err := repositories.ChannelRepository.UpdatesConfigIfUnchanged(sqls.DB(), expected, columns)
if err != nil { return errorsx.BusinessErrorI18n(0, "error.channel.configUpdateFailed") }
if !updated { return errorsx.InvalidParamI18n("error.channel.configChanged") }
return nil
}

View File

@@ -1,3 +1,4 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services
import (
@@ -151,10 +152,12 @@ func (s *conversationDispatchService) DispatchPendingConversations(limit int) (i
if limit <= 0 {
limit = pendingDispatchBatchLimit
}
conversations := ConversationService.Find(sqls.NewCnd().
Eq("status", enums.IMConversationStatusPending).
Eq("current_assignee_id", 0).
Desc("id"))
// Preserve the existing newest-first batch/periodic retry policy, but
// bound the SQL read and distinguish failure from an empty queue.
conversations, err := repositories.ConversationRepository.FindPendingDispatchBatch(sqls.DB(), limit)
if err != nil {
return 0, err
}
if len(conversations) == 0 {
return 0, nil
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -112,11 +114,13 @@ func findHumanDispatchRealtimeEvent(t *testing.T, session *ClientSession, eventT
func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *ClientSession {
t.Helper()
principal := seedRealtimeStaffPrincipal(t, sqls.DB())
session := &ClientSession{
ID: "test-session",
Role: realtimeRoleAdmin,
Topics: map[string]struct{}{},
Send: make(chan []byte, 32),
ID: "test-session",
Role: realtimeRoleAdmin,
Principal: principal,
Topics: map[string]struct{}{},
Send: make(chan []byte, 32),
}
WsService.manager.Register(session, topics)
return session
@@ -124,6 +128,7 @@ func captureHumanDispatchRealtimeSession(t *testing.T, topics ...string) *Client
func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Helper()
previousDB, previousWs := sqls.DB(), WsService
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
@@ -161,6 +166,10 @@ func setupHumanDispatchRealtimeTestDB(t *testing.T) *gorm.DB {
t.Fatalf("auto migrate error = %v", err)
}
sqls.SetDB(db)
t.Cleanup(func() {
sqls.SetDB(previousDB)
WsService = previousWs
})
return db
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -317,17 +319,14 @@ func (s *conversationService) CreateNew(externalUser openidentity.ExternalUser,
// activity first, so the widget can offer a request list. A visitor who has not
// talked to us yet has no identity row and therefore an empty list, not an error.
//
// Scoping is by customer, not by channel: a CustomerIdentity is keyed by
// (external_source, external_id) globally, so one signed visitor reaching us
// through two channels is one person with one request history. For a guest the
// external id is issued per channel, so their identities cannot collide across
// channels anyway. Narrowing this to the current channel would silently hide the
// visitor's earlier requests.
// Public identities are scoped to a server-verified issuer and channel. Legacy
// internal integrations may still use their explicitly unscoped identity mapping.
func (s *conversationService) ListCustomerConversations(externalUser openidentity.ExternalUser, cnd *sqls.Cnd) ([]models.Conversation, *sqls.Paging, error) {
if cnd == nil {
cnd = sqls.NewCnd()
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, strings.TrimSpace(externalUser.ExternalID))
if err != nil { return nil, nil, errorsx.UnauthorizedI18n("error.e0161") }
if identity == nil {
paging := &sqls.Paging{}
if cnd.Paging != nil {
@@ -336,6 +335,7 @@ func (s *conversationService) ListCustomerConversations(externalUser openidentit
return []models.Conversation{}, paging, nil
}
cnd.Eq("customer_id", identity.CustomerID).Desc("last_active_at").Desc("id")
if externalUser.ChannelID > 0 { cnd.Eq("channel_id", externalUser.ChannelID) }
list, paging := repositories.ConversationRepository.FindPageByCnd(sqls.DB(), cnd)
if list == nil {
list = []models.Conversation{}
@@ -832,8 +832,9 @@ func (s *conversationService) IsCustomerConversationOwner(conversation *models.C
if extID == "" || strings.TrimSpace(string(externalUser.ExternalSource)) == "" || conversation.CustomerID <= 0 {
return false
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), externalUser.ExternalSource, extID)
if identity == nil {
if externalUser.ChannelID > 0 && externalUser.ChannelID != conversation.ChannelID { return false }
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, extID)
if err != nil || identity == nil {
return false
}
return identity.CustomerID == conversation.CustomerID

View File

@@ -0,0 +1,272 @@
package services
import (
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/repositories"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/golang-jwt/jwt/v5"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
func setupIdentitySecurityDB(t *testing.T) (*gorm.DB, *models.Channel) {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "identity.db")), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil { t.Fatal(err) }
if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Channel{}, &models.Conversation{}); err != nil { t.Fatal(err) }
previousDB, previousConfig := sqls.DB(), config.GetCurrent()
sqls.SetDB(db)
config.SetCurrent(&config.Config{CustomerSession: config.CustomerSessionConfig{Secret: "test-only-customer-session-key", TTLMinutes: 120}})
t.Cleanup(func() { sqls.SetDB(previousDB); config.SetCurrent(previousConfig); sqlDB, _ := db.DB(); _ = sqlDB.Close() })
channel := &models.Channel{ChannelID: "channel-a", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
if err := db.Create(channel).Error; err != nil { t.Fatal(err) }
return db, channel
}
func scopedTestExternal(channel *models.Channel, issuer, id string) openidentity.ExternalUser {
source := enums.ExternalSourceUser
if issuer == "guest" { source = enums.ExternalSourceGuest }
return openidentity.ExternalUser{ExternalSource: source, ExternalID: id, IdentityScope: openidentity.IdentityScope(channel.ChannelID, issuer), ChannelID: channel.ID}
}
func verifySecurityCustomerSession(t *testing.T, channel *models.Channel, token string) *CustomerSessionVerifyResult {
t.Helper()
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("GET", "/api/ws/open", nil)
ctx.Request.Header.Set("Authorization", "Bearer " + token)
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil { t.Fatalf("verify customer credential: %v", err) }
return result
}
func TestCustomerIdentitySeparatesTrustIssuerAndChannel(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
otherChannel := &models.Channel{ChannelID: "channel-b", ChannelType: enums.ChannelTypeWeb, Status: enums.StatusOk, ConfigJSON: "{}"}
if err := db.Create(otherChannel).Error; err != nil { t.Fatal(err) }
customerIDs := map[int64]bool{}
for _, input := range []struct{ channel *models.Channel; issuer string }{{channel,"hmac"},{channel,"jwt"},{channel,"guest"},{otherChannel,"hmac"}} {
exchanged, err := CustomerSessionService.Exchange(input.channel, scopedTestExternal(input.channel, input.issuer, "same-id"))
if err != nil { t.Fatal(err) }
if customerIDs[exchanged.Customer.ID] { t.Fatal("independent identity scopes were merged") }
customerIDs[exchanged.Customer.ID] = true
result := verifySecurityCustomerSession(t, input.channel, exchanged.CustomerSessionToken)
if !CustomerSessionService.VerifyConnection(result) { t.Fatal("fresh scoped connection rejected") }
wrongChannel := &models.Conversation{CustomerID: result.CustomerID, ChannelID: otherChannel.ID}
if input.channel.ID != otherChannel.ID && ConversationService.IsCustomerConversationOwner(wrongChannel, *result.ExternalUser) { t.Fatal("customer acquired a different channel's conversation") }
}
}
func TestGuestBootstrapCannotReclaimKnownAnonymousIdentity(t *testing.T) {
_, channel := setupIdentitySecurityDB(t)
first, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest","caller-chosen-id"))
if err != nil { t.Fatal(err) }
stolenID := strings.TrimPrefix(first.IdentityKey,"guest:")
if len(stolenID) != 64 || stolenID == "caller-chosen-id" { t.Fatal("guest identity was not server-generated") }
second, err := CustomerSessionService.Exchange(channel, scopedTestExternal(channel,"guest",stolenID))
if err != nil { t.Fatal(err) }
if first.Customer.ID == second.Customer.ID || first.IdentityKey == second.IdentityKey { t.Fatal("public guest bootstrap reclaimed another guest identity") }
original := verifySecurityCustomerSession(t,channel,first.CustomerSessionToken)
if original.CustomerID != first.Customer.ID { t.Fatal("signed guest credential did not retain its owner") }
}
func TestCustomerIdentityDuplicatesFailClosedWithoutCreatingAnotherCustomer(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
external := scopedTestExternal(channel,"hmac","duplicate")
for i:=0; i<2; i++ {
customer := &models.Customer{Status: enums.StatusOk}
if err := db.Create(customer).Error; err != nil { t.Fatal(err) }
identity := &models.CustomerIdentity{CustomerID: customer.ID, IdentityScope: external.IdentityScope, ExternalSource: external.ExternalSource, ExternalID: external.ExternalID, Status: enums.StatusOk}
if err := db.Create(identity).Error; err != nil { t.Fatal(err) }
}
if _, err := CustomerSessionService.Exchange(channel,external); err == nil { t.Fatal("ambiguous identity was accepted") }
var count int64
if err := db.Model(&models.Customer{}).Count(&count).Error; err != nil { t.Fatal(err) }
if count != 2 { t.Fatalf("ambiguous mapping created another customer: %d",count) }
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(db,external.IdentityScope,external.ExternalSource,external.ExternalID)
if err == nil || identity != nil { t.Fatal("duplicate lookup did not fail closed") }
}
func TestCustomerSessionsRejectLegacyScopeAndLivePolicyChanges(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest"))
if err != nil { t.Fatal(err) }
current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken)
claims, err := CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
if err != nil { t.Fatal(err) }
claims.IdentityScope = ""
legacy, err := jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err != nil { t.Fatal(err) }
if _, err := CustomerSessionService.verifyToken(legacy); err == nil { t.Fatal("unscoped legacy credential accepted") }
if err := db.Model(channel).Update("config_json",`{"requireSignedIdentity":true}`).Error; err != nil { t.Fatal(err) }
if CustomerSessionService.VerifyConnection(current) { t.Fatal("existing guest retained access after signed-only policy change") }
channel.ConfigJSON=`{"requireSignedIdentity":true}`
if _, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","guest")); err == nil { t.Fatal("signed-only channel accepted guest exchange") }
}
func TestCustomerConnectionRejectsExpiryDisabledCustomerAndIdentityChanges(t *testing.T) {
for _, scenario := range []string{"expiry","disabled-customer","disabled-channel","disabled-identity","rebound-identity","duplicate-identity"} {
t.Run(scenario,func(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
exchanged, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","signed"))
if err != nil { t.Fatal(err) }
current := verifySecurityCustomerSession(t,channel,exchanged.CustomerSessionToken)
switch scenario {
case "expiry": current.ExpiresAt=time.Now()
case "disabled-customer": err=db.Model(&models.Customer{}).Where("id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error
case "disabled-channel": err=db.Model(channel).Update("status",enums.StatusDisabled).Error
case "disabled-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("status",enums.StatusDisabled).Error
case "rebound-identity": err=db.Model(&models.CustomerIdentity{}).Where("customer_id = ?",current.CustomerID).Update("customer_id",current.CustomerID+100).Error
case "duplicate-identity": err=db.Create(&models.CustomerIdentity{CustomerID:current.CustomerID+100,ExternalSource:current.ExternalUser.ExternalSource,ExternalID:current.ExternalUser.ExternalID,IdentityScope:current.ExternalUser.IdentityScope,Status:enums.StatusOk}).Error
}
if err != nil { t.Fatal(err) }
if CustomerSessionService.VerifyConnection(current) { t.Fatal("invalidated customer connection retained authority") }
})
}
}
func TestCustomerRealtimeTopicsAndDeliveryRejectCrossScopeCollisions(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
ws := newWsService()
signed, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"hmac","known-id"))
if err != nil { t.Fatal(err) }
guest, err := CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","known-id"))
if err != nil { t.Fatal(err) }
signedAuth:=verifySecurityCustomerSession(t,channel,signed.CustomerSessionToken)
guestAuth:=verifySecurityCustomerSession(t,channel,guest.CustomerSessionToken)
conversation:= &models.Conversation{CustomerID:signed.Customer.ID,ChannelID:channel.ID}
if err:=db.Create(conversation).Error;err!=nil { t.Fatal(err) }
owner:=&ClientSession{ID:"owner",Role:realtimeRoleUser,External:signedAuth.ExternalUser,CustomerSession:signedAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)}
attacker:=&ClientSession{ID:"guest",Role:realtimeRoleUser,External:guestAuth.ExternalUser,CustomerSession:guestAuth,Topics:map[string]struct{}{},Send:make(chan []byte,4)}
if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(guest.Customer.ID,channel.ID) { t.Fatal("trust namespaces collided") }
if ws.customerTopic(signed.Customer.ID,channel.ID)==ws.customerTopic(signed.Customer.ID,channel.ID+1) { t.Fatal("channel namespaces collided") }
ws.manager.Register(owner,ws.defaultTopics(owner))
// Defense in depth: even an accidentally stale/wrong topic registration may
// not grant the guest authorization to the signed customer's conversation.
ws.manager.Register(attacker,ws.routeConversationTopics(conversation))
ws.PublishAgentActivity(conversation,"step","","running")
select { case <-owner.Send: default: t.Fatal("owner did not receive its event") }
select { case <-attacker.Send: t.Fatal("guest received another customer's event"); default: }
if ws.canSubscribeConversation(attacker,conversation.ID) { t.Fatal("explicit foreign conversation subscription allowed") }
}
func TestScopedIdentityUniqueClaimPreservesLegacyRows(t *testing.T) {
db, channel := setupIdentitySecurityDB(t)
source := enums.ExternalSourceUser
// Dirty legacy mappings remain migratable because their unique claim is NULL.
for _, customerID := range []int64{100,101} {
if err := db.Create(&models.CustomerIdentity{CustomerID:customerID,ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal(err) }
}
scope:=openidentity.IdentityScope(channel.ChannelID,"hmac")
first:=&models.CustomerIdentity{CustomerID:102,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}
if err:=db.Create(first).Error;err!=nil { t.Fatal(err) }
competing:=&models.CustomerIdentity{CustomerID:103,IdentityScope:scope,ScopedKey:repositories.ScopedCustomerIdentityKey(scope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}
if err:=db.Create(competing).Error;err==nil { t.Fatal("database allowed a competing scoped identity claim") }
otherScope:=openidentity.IdentityScope(channel.ChannelID,"jwt")
if err:=db.Create(&models.CustomerIdentity{CustomerID:104,IdentityScope:otherScope,ScopedKey:repositories.ScopedCustomerIdentityKey(otherScope,source,"same"),ExternalSource:source,ExternalID:"same",Status:enums.StatusOk}).Error;err!=nil { t.Fatal("different issuer's claim was conflated") }
found,err:=repositories.CustomerIdentityRepository.GetUniqueBy(db,scope,source,"same")
if err!=nil || found==nil || found.CustomerID!=102 { t.Fatal("legacy mappings interfered with scoped identity") }
}
func TestWebIdentityPolicySurvivesOlderClientEdits(t *testing.T) {
svc:=newChannelService()
raw,err:=svc.preserveWebIdentityPolicy(`{"title":"New title"}`,`{"requireSignedIdentity":true}`)
if err!=nil { t.Fatal(err) }
cfg,err:=svc.ParseWebChannelConfig(raw)
if err!=nil || !cfg.RequireSignedIdentity { t.Fatal("older client edit silently disabled signed-only policy") }
raw,err=svc.preserveWebIdentityPolicy(`{"requireSignedIdentity":false}`,`{"requireSignedIdentity":true}`)
if err!=nil { t.Fatal(err) }
cfg,err=svc.ParseWebChannelConfig(raw)
if err!=nil || cfg.RequireSignedIdentity { t.Fatal("explicit policy change was not honored") }
for _,raw:=range []string{`{"requireSignedIdentity":null}`,`{"requireSignedIdentity":"false"}`,`{"requireSignedIdentity":0}`} {
if _,err:=svc.ParseWebChannelConfig(raw);err==nil { t.Fatal("non-boolean authentication policy accepted") }
}
}
func TestCustomerSessionNearExpiryRefreshPreservesIdentity(t *testing.T) {
db,channel:=setupIdentitySecurityDB(t)
exchanged,err:=CustomerSessionService.Exchange(channel,scopedTestExternal(channel,"guest","local-context"))
if err!=nil{t.Fatal(err)}
claims,err:=CustomerSessionService.verifyToken(exchanged.CustomerSessionToken)
if err!=nil{t.Fatal(err)}
originalKey,originalScope,originalCustomer:=claims.IdentityKey,claims.IdentityScope,claims.CustomerID
claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(20*time.Second))
nearExpiry,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err!=nil{t.Fatal(err)}
refreshed:=verifySecurityCustomerSession(t,channel,nearExpiry)
if !refreshed.Refreshed || !refreshed.ExpiresAt.After(claims.ExpiresAt.Time) || refreshed.Token==nearExpiry {t.Fatal("near-expiry credential was not refreshed")}
refreshedClaims,err:=CustomerSessionService.verifyToken(refreshed.Token)
if err!=nil{t.Fatal(err)}
if refreshedClaims.IdentityKey!=originalKey || refreshedClaims.IdentityScope!=originalScope || refreshedClaims.CustomerID!=originalCustomer {t.Fatal("refresh changed anonymous identity")}
var customers int64
if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)}
if customers!=1{t.Fatal("refresh allocated another customer")}
claims.ExpiresAt=jwt.NewNumericDate(time.Now().Add(-time.Second))
expired,err:=jwt.NewWithClaims(jwt.SigningMethodHS256,claims).SignedString([]byte(config.Current().CustomerSession.Secret))
if err!=nil{t.Fatal(err)}
ctx,_:=gin.CreateTestContext(httptest.NewRecorder())
ctx.Request=httptest.NewRequest("POST","/api/customer/session_refresh",nil)
ctx.Request.Header.Set("Authorization","Bearer "+expired)
if _,err:=CustomerSessionService.VerifyRequest(ctx,channel);err==nil{t.Fatal("expired anonymous credential was silently renewed")}
}
func TestConcurrentCustomerSessionExchangesKeepOneScopedOwner(t *testing.T) {
db,channel:=setupIdentitySecurityDB(t)
if err:=db.Exec("PRAGMA journal_mode=WAL").Error;err!=nil{t.Fatal(err)}
pool,err:=db.DB();if err!=nil{t.Fatal(err)}
pool.SetMaxOpenConns(4)
external:=scopedTestExternal(channel,"hmac","concurrent-id")
var queries atomic.Int32
arrived:=make(chan struct{},2)
release:=make(chan struct{})
var releaseOnce sync.Once
releaseBoth:=func(){releaseOnce.Do(func(){close(release)})}
var exchanges sync.WaitGroup
defer func(){releaseBoth();exchanges.Wait()}()
callbackName:="test:concurrent_scoped_lookup"
if err:=db.Callback().Query().After("gorm:query").Register(callbackName,func(tx *gorm.DB){
if tx.Statement.Table!="t_customer_identity" || tx.Error!=nil || tx.RowsAffected!=0{return}
if queries.Add(1)<=2{arrived<-struct{}{};<-release}
});err!=nil{t.Fatal(err)}
t.Cleanup(func(){_ = db.Callback().Query().Remove(callbackName)})
type result struct{ value *response.CustomerSessionExchangeResponse; err error }
results:=make(chan result,2)
exchanges.Add(2)
for i:=0;i<2;i++{go func(){defer exchanges.Done();value,err:=CustomerSessionService.Exchange(channel,external);results<-result{value,err}}()}
for i:=0;i<2;i++{select{case <-arrived:case <-time.After(2*time.Second):releaseBoth();t.Fatal("concurrent transactions did not both reach initial identity lookup")}}
releaseBoth()
successes:=0
var owner int64
for i:=0;i<2;i++{
select{
case result:=<-results:
if result.err==nil{successes++;owner=result.value.Customer.ID}
case <-time.After(2*time.Second):t.Fatal("concurrent exchange did not finish")
}
}
if successes!=1{t.Fatalf("expected one transaction winner, got %d",successes)}
var customers,identities int64
if err:=db.Model(&models.Customer{}).Count(&customers).Error;err!=nil{t.Fatal(err)}
if err:=db.Model(&models.CustomerIdentity{}).Count(&identities).Error;err!=nil{t.Fatal(err)}
if customers!=1 || identities!=1{t.Fatal("losing transaction left an orphan or competing customer mapping")}
retry,err:=CustomerSessionService.Exchange(channel,external)
if err!=nil || retry.Customer.ID!=owner{t.Fatal("retry did not resolve the unique winning owner")}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -124,7 +126,11 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
return 0, errorsx.UnauthorizedI18n("error.e0149")
}
now := time.Now()
if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil {
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(ctx.Tx, externalUser.IdentityScope, externalSource, externalID)
if err != nil { return 0, errorsx.UnauthorizedI18n("error.e0161") }
if identity != nil {
customer := repositories.CustomerRepository.Get(ctx.Tx, identity.CustomerID)
if customer == nil || customer.Status != enums.StatusOk { return 0, errorsx.UnauthorizedI18n("error.e0161") }
updates := map[string]any{
"last_active_at": now,
"updated_at": now,
@@ -161,12 +167,16 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs
}
if err := repositories.CustomerIdentityRepository.Create(ctx.Tx, &models.CustomerIdentity{
CustomerID: customer.ID,
IdentityScope: externalUser.IdentityScope,
ScopedKey: repositories.ScopedCustomerIdentityKey(externalUser.IdentityScope, externalSource, externalID),
ExternalSource: externalSource,
ExternalID: externalID,
Status: enums.StatusOk,
AuditFields: utils.BuildAuditFields(nil),
}); err != nil {
return 0, err
// A concurrent scoped claim rolls the transaction back; retrying the
// exchange resolves the winning mapping. Do not expose SQL/key data.
return 0, errorsx.UnauthorizedI18n("error.e0161")
}
return customer.ID, nil
}

View File

@@ -1,7 +1,11 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
"errors"
"crypto/rand"
"encoding/hex"
"strings"
"time"
@@ -42,10 +46,13 @@ type customerSessionClaims struct {
CustomerID int64 `json:"customerId"`
CustomerName string `json:"customerName"`
IdentityKey string `json:"identityKey"`
IdentityScope string `json:"identityScope"`
jwt.RegisteredClaims
}
type CustomerSessionVerifyResult struct {
CustomerID int64
ChannelID int64
ExternalUser *openidentity.ExternalUser
Token string
ExpiresAt time.Time
@@ -56,7 +63,18 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
if channel == nil || channel.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0209")
}
var customerID int64
if !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) || (ChannelService.RequiresSignedIdentity(channel) && externalUser.ExternalSource != enums.ExternalSourceUser) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
externalUser.ChannelID = channel.ID
if externalUser.ExternalSource == enums.ExternalSourceGuest {
// A public caller's guest ID is not proof of possession. Only the signed
// customer session can resume this server-issued anonymous identity.
var id [32]byte
if _, err := rand.Read(id[:]); err != nil { return nil, err }
externalUser.ExternalID = hex.EncodeToString(id[:])
}
var customerID int64
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
id, err := CustomerService.EnsureExternalCustomer(ctx, externalUser)
if err != nil {
@@ -68,10 +86,12 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser
return nil, err
}
customer := CustomerService.Get(customerID)
if customer == nil || customer.Status == enums.StatusDeleted {
if customer == nil || customer.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0155")
}
token, expiresAt, err := s.Sign(channel, customer, externalUser)
identity, identityErr := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), externalUser.IdentityScope, externalUser.ExternalSource, externalUser.ExternalID)
if identityErr != nil || identity == nil || identity.CustomerID != customer.ID { return nil, errorsx.UnauthorizedI18n("error.e0161") }
token, expiresAt, err := s.Sign(channel, customer, externalUser)
if err != nil {
return nil, err
}
@@ -92,7 +112,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
if secret == "" {
return "", time.Time{}, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing")
}
if channel == nil || customer == nil {
if channel == nil || customer == nil || !openidentity.ValidIdentityScope(channel.ChannelID, externalUser) {
return "", time.Time{}, errorsx.InvalidParamI18n("error.e0158")
}
now := time.Now()
@@ -104,6 +124,7 @@ func (s *customerSessionService) Sign(channel *models.Channel, customer *models.
CustomerID: customer.ID,
CustomerName: strings.TrimSpace(customer.Name),
IdentityKey: s.identityKey(externalUser),
IdentityScope: externalUser.IdentityScope,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expiresAt),
@@ -125,27 +146,11 @@ func (s *customerSessionService) VerifyRequest(ctx *gin.Context, channel *models
if err != nil {
return nil, err
}
if channel == nil || channel.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0209")
}
if claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
customer := CustomerService.Get(claims.CustomerID)
if customer == nil || customer.Status == enums.StatusDeleted {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
external, err := s.externalUserFromClaims(claims, customer)
if err != nil {
return nil, err
}
result := &CustomerSessionVerifyResult{
ExternalUser: external,
Token: token,
ExpiresAt: claims.ExpiresAt.Time,
}
result, err := s.verifyClaims(claims, channel)
if err != nil { return nil, err }
result.Token = token
if s.shouldRefresh(claims.ExpiresAt.Time) {
newToken, expiresAt, err := s.Sign(channel, customer, *external)
newToken, expiresAt, err := s.Sign(channel, CustomerService.Get(result.CustomerID), *result.ExternalUser)
if err != nil {
return nil, err
}
@@ -190,7 +195,7 @@ func (s *customerSessionService) verifyToken(rawToken string) (*customerSessionC
if token == nil || !token.Valid || claims.TokenType != customerSessionTokenType || claims.ExpiresAt == nil {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" {
if claims.ChannelID <= 0 || strings.TrimSpace(claims.ChannelCode) == "" || claims.CustomerID <= 0 || strings.TrimSpace(claims.IdentityKey) == "" || strings.TrimSpace(claims.IdentityScope) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return claims, nil
@@ -211,8 +216,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
default:
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
identity := repositories.CustomerIdentityRepository.GetBy(sqls.DB(), source, parts[1])
if identity == nil || identity.CustomerID != claims.CustomerID {
identity, err := repositories.CustomerIdentityRepository.GetUniqueBy(sqls.DB(), claims.IdentityScope, source, parts[1])
if err != nil || identity == nil || identity.CustomerID != claims.CustomerID {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
name := strings.TrimSpace(claims.CustomerName)
@@ -223,6 +228,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC
ExternalSource: source,
ExternalID: parts[1],
ExternalName: name,
IdentityScope: claims.IdentityScope,
ChannelID: claims.ChannelID,
}, nil
}
@@ -250,3 +257,30 @@ func (s *customerSessionService) getCustomerSessionToken(ctx *gin.Context) strin
token, _ := params.Get(ctx, "customerSessionToken")
return strings.TrimSpace(token)
}
// VerifyConnection rechecks the signed credential, expiry, channel policy and
// current identity mapping. It never refreshes an idle connection indefinitely.
func (s *customerSessionService) VerifyConnection(result *CustomerSessionVerifyResult) bool {
if result == nil || !time.Now().Before(result.ExpiresAt) { return false }
claims, err := s.verifyToken(result.Token)
if err != nil || claims.CustomerID != result.CustomerID || claims.ChannelID != result.ChannelID { return false }
verified, err := s.verifyClaims(claims, ChannelService.Get(claims.ChannelID))
return err == nil && verified.ExternalUser != nil && result.ExternalUser != nil &&
verified.ExternalUser.IdentityScope == result.ExternalUser.IdentityScope &&
verified.ExternalUser.ExternalSource == result.ExternalUser.ExternalSource &&
verified.ExternalUser.ExternalID == result.ExternalUser.ExternalID
}
func (s *customerSessionService) verifyClaims(claims *customerSessionClaims, channel *models.Channel) (*CustomerSessionVerifyResult, error) {
if channel == nil || channel.Status != enums.StatusOk || claims.ChannelID != channel.ID || strings.TrimSpace(claims.ChannelCode) != strings.TrimSpace(channel.ChannelID) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
customer := CustomerService.Get(claims.CustomerID)
if customer == nil || customer.Status != enums.StatusOk { return nil, errorsx.UnauthorizedI18n("error.e0161") }
external, err := s.externalUserFromClaims(claims, customer)
if err != nil { return nil, err }
if !openidentity.ValidIdentityScope(channel.ChannelID, *external) || (ChannelService.RequiresSignedIdentity(channel) && external.ExternalSource != enums.ExternalSourceUser) {
return nil, errorsx.UnauthorizedI18n("error.e0161")
}
return &CustomerSessionVerifyResult{CustomerID: customer.ID, ChannelID: channel.ID, ExternalUser: external, ExpiresAt: claims.ExpiresAt.Time}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,10 @@
// Modified in AgentDeskFixes: safe, reviewed setup repairs and honest routing diagnostics.
package services_test
import (
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
@@ -9,6 +13,7 @@ import (
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/services"
@@ -59,378 +64,274 @@ func setupSetupReadinessTestDB(t *testing.T) *gorm.DB {
return db
}
func TestSetupReadiness_EmptyDBAndQuickSetupAll(t *testing.T) {
db := setupSetupReadinessTestDB(t)
admin := models.User{
Username: "admin",
Nickname: "Admin Operator",
Status: enums.StatusOk,
}
if err := db.Create(&admin).Error; err != nil {
t.Fatalf("create admin error = %v", err)
}
principal := &dto.AuthPrincipal{
UserID: admin.ID,
Username: admin.Username,
Nickname: admin.Nickname,
Roles: []string{constants.RoleCodeSuperAdmin},
}
readiness, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
}
if readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = false on empty DB")
}
if readiness.ReadySteps != 0 {
t.Fatalf("expected ReadySteps = 0 on empty DB, got %d", readiness.ReadySteps)
}
if readiness.TotalSteps != 6 {
t.Fatalf("expected TotalSteps = 6, got %d", readiness.TotalSteps)
}
if readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = false on empty DB")
}
fixResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "quick_setup_all",
}, principal)
if err != nil {
t.Fatalf("ApplyQuickFix(quick_setup_all) error = %v", err)
}
if !fixResp.Readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = true after quick_setup_all, steps = %+v", fixResp.Readiness.Steps)
}
if fixResp.Readiness.ReadySteps != 6 {
t.Fatalf("expected ReadySteps = 6 after quick_setup_all, got %d", fixResp.Readiness.ReadySteps)
}
if !fixResp.Readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after quick_setup_all, blockers = %v", fixResp.Readiness.CurrentOperator.BlockerCodes)
}
// Second call must be idempotent
fixResp2, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "quick_setup_all",
}, principal)
if err != nil {
t.Fatalf("second ApplyQuickFix(quick_setup_all) error = %v", err)
}
if !fixResp2.Readiness.CanReceiveRequestsNow {
t.Fatalf("expected CanReceiveRequestsNow = true after second quick_setup_all")
}
var teamCount int64
db.Model(&models.AgentTeam{}).Count(&teamCount)
if teamCount != 1 {
t.Fatalf("expected idempotent 1 team, got %d", teamCount)
}
}
func TestSetupReadiness_OffHoursAndBusyOperator(t *testing.T) {
db := setupSetupReadinessTestDB(t)
now := time.Now()
opUser := models.User{
Username: "operator1",
Nickname: "Operator One",
Status: enums.StatusOk,
}
if err := db.Create(&opUser).Error; err != nil {
t.Fatalf("create user error = %v", err)
}
team := models.AgentTeam{
Name: "Support Team",
Status: enums.StatusOk,
}
if err := db.Create(&team).Error; err != nil {
t.Fatalf("create team error = %v", err)
}
// Schedule in the past (off-hours now)
pastSchedule := models.AgentTeamSchedule{
TeamID: team.ID,
StartAt: now.Add(-48 * time.Hour),
EndAt: now.Add(-24 * time.Hour),
Status: enums.StatusOk,
}
if err := db.Create(&pastSchedule).Error; err != nil {
t.Fatalf("create past schedule error = %v", err)
}
// Operator is Busy and AutoAssignEnabled = false
profile := models.AgentProfile{
UserID: opUser.ID,
TeamID: team.ID,
AgentCode: "OP-001",
DisplayName: "Operator One",
ServiceStatus: enums.ServiceStatusBusy,
MaxConcurrentCount: 5,
AutoAssignEnabled: false,
Status: enums.StatusOk,
}
if err := db.Create(&profile).Error; err != nil {
t.Fatalf("create profile error = %v", err)
}
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil {
t.Fatalf("update auto_assign_enabled error = %v", err)
}
principal := &dto.AuthPrincipal{
UserID: opUser.ID,
Username: opUser.Username,
Nickname: opUser.Nickname,
Roles: []string{constants.RoleCodeAdmin},
}
readiness, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
}
if readiness.CurrentOperator.TeamHasScheduleNow {
t.Fatalf("expected TeamHasScheduleNow = false for past schedule")
}
if readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = false when busy and off-hours")
}
// Fix operator profile and schedule
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "enable_my_operator_profile",
}, principal)
if err != nil {
t.Fatalf("enable_my_operator_profile error = %v", err)
}
readinessAfter, err := services.SetupReadinessService.GetReadiness(principal)
if err != nil {
t.Fatalf("GetReadiness after fix error = %v", err)
}
if !readinessAfter.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after enable_my_operator_profile, blockers = %v", readinessAfter.CurrentOperator.BlockerCodes)
}
}
func TestSetupReadiness_SimulateRoute_AIOnlyAndMissingTeams(t *testing.T) {
db := setupSetupReadinessTestDB(t)
opUser := models.User{
Username: "op2",
Nickname: "Operator Two",
Status: enums.StatusOk,
}
db.Create(&opUser)
principal := &dto.AuthPrincipal{
UserID: opUser.ID,
Username: opUser.Username,
Nickname: opUser.Nickname,
Roles: []string{constants.RoleCodeAdmin},
}
// Setup team + active schedule + idle operator
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "enable_my_operator_profile",
}, principal)
if err != nil {
t.Fatalf("enable_my_operator_profile error = %v", err)
}
// Create AI Agent in AIOnly mode and empty TeamIDs
agent := models.AIAgent{
Name: "AI Bot",
Status: enums.StatusOk,
ServiceMode: enums.IMConversationServiceModeAIOnly,
RolloutPercent: 100,
TeamIDs: "",
}
db.Create(&agent)
channel := models.Channel{
Name: "Web Widget",
ChannelType: "web",
ChannelID: "web-test-1",
AIAgentID: agent.ID,
AIAgentRolloutPercent: 100,
Status: enums.StatusOk,
}
db.Create(&channel)
sim1, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 1 error = %v", err)
}
if sim1.WillReachOperator {
t.Fatalf("expected WillReachOperator = false when AIOnly")
}
if sim1.FinalOutcomeCode != "blocked_ai_only_mode" {
t.Fatalf("expected blocked_ai_only_mode, got %s", sim1.FinalOutcomeCode)
}
// Switch to HumanOnly but still empty TeamIDs
db.Model(&agent).Update("service_mode", enums.IMConversationServiceModeHumanOnly)
sim2, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 2 error = %v", err)
}
if sim2.WillReachOperator {
t.Fatalf("expected WillReachOperator = false when TeamIDs is empty")
}
if sim2.FinalOutcomeCode != "blocked_agent_no_teams" {
t.Fatalf("expected blocked_agent_no_teams, got %s", sim2.FinalOutcomeCode)
}
// Fix AI Agent handoff via quick fix
_, err = services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: "fix_ai_agent_handoff",
EntityID: agent.ID,
}, principal)
if err != nil {
t.Fatalf("fix_ai_agent_handoff error = %v", err)
}
sim3, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{
ChannelID: channel.ID,
BypassAI: true,
}, principal)
if err != nil {
t.Fatalf("SimulateRoute 3 error = %v", err)
}
if !sim3.WillReachOperator {
t.Fatalf("expected WillReachOperator = true after fix_ai_agent_handoff, got outcome %s", sim3.FinalOutcomeCode)
}
if sim3.FinalOutcomeCode != "assigned_to_operator" {
t.Fatalf("expected assigned_to_operator, got %s", sim3.FinalOutcomeCode)
}
if sim3.MatchedOperator == nil || sim3.MatchedOperator.UserID != opUser.ID {
t.Fatalf("expected MatchedOperator UserID = %d, got %+v", opUser.ID, sim3.MatchedOperator)
}
}
func TestSetupReadiness_QuickFixPermissionEnforcement(t *testing.T) {
db := setupSetupReadinessTestDB(t)
csUser := models.User{
Username: "cs_agent",
Nickname: "Support Agent",
Status: enums.StatusOk,
}
if err := db.Create(&csUser).Error; err != nil {
t.Fatalf("create csUser error = %v", err)
}
// Regular cs_user only has view permissions
csPrincipal := &dto.AuthPrincipal{
UserID: csUser.ID,
Username: csUser.Username,
Nickname: csUser.Nickname,
Roles: []string{constants.RoleCodeCsUser},
func setupRepairPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
t.Helper()
user := models.User{Username: "setup-admin", Nickname: "Setup Admin", Status: enums.StatusOk}
if err := db.Create(&user).Error; err != nil { t.Fatal(err) }
return &dto.AuthPrincipal{
UserID: user.ID, Username: user.Username, Nickname: user.Nickname,
Roles: []string{constants.RoleCodeAdmin},
Permissions: []string{
constants.PermissionAgentView.Code,
constants.PermissionAgentTeamView.Code,
constants.PermissionAgentTeamScheduleView.Code,
constants.PermissionAIAgentView.Code,
constants.PermissionChannelView.Code,
constants.PermissionAgentTeamCreate.Code, constants.PermissionAgentTeamUpdate.Code,
constants.PermissionAgentTeamScheduleCreate.Code, constants.PermissionAgentTeamScheduleUpdate.Code,
constants.PermissionAgentCreate.Code, constants.PermissionAgentUpdate.Code,
constants.PermissionAgentConfig.Code, constants.PermissionAgentUpdateStatus.Code,
constants.PermissionAIAgentCreate.Code, constants.PermissionAIAgentUpdate.Code,
constants.PermissionChannelCreate.Code, constants.PermissionChannelUpdate.Code,
},
}
}
// 1. GetReadiness for cs_user without mutation permissions must not expose quick-fix actions
readiness, err := services.SetupReadinessService.GetReadiness(csPrincipal)
if err != nil {
t.Fatalf("GetReadiness error = %v", err)
func applyReviewedSetupFix(t *testing.T, req request.SetupQuickFixRequest, principal *dto.AuthPrincipal) *response.SetupQuickFixResponse {
t.Helper()
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatalf("preview: %v", err) }
if preview.Applied || preview.PreviewToken == "" { t.Fatalf("expected read-only preview: %+v", preview) }
req.Apply, req.PreviewToken = true, preview.PreviewToken
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatalf("apply: %v", err) }
if !applied.Applied { t.Fatal("reviewed changes were not applied") }
return applied
}
func assertSetupCount(t *testing.T, db *gorm.DB, model any, want int64) {
t.Helper()
var count int64
if err := db.Model(model).Count(&count).Error; err != nil { t.Fatal(err) }
if count != want { t.Fatalf("%T count = %d, want %d", model, count, want) }
}
func TestSetupReadiness_RequiresReviewedExplicitApply(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatal(err) }
if preview.Applied || len(preview.Changes) != 3 { t.Fatalf("expected three proposed draft setup records: %+v", preview) }
for _, change := range preview.Changes {
if change.EntityName == "" || len(change.After) == 0 { t.Fatalf("unnamed or unspecified change: %+v", change) }
}
for _, step := range readiness.Steps {
if len(step.QuickFixActions) != 0 {
t.Fatalf("expected step %s QuickFixActions to be empty for cs_user without write permissions, got %v", step.Key, step.QuickFixActions)
}
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}, &models.AgentTeamSchedule{}} { assertSetupCount(t, db, model, 0) }
req.Apply = true
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("apply without preview must fail") }
req.PreviewToken = preview.PreviewToken
applied, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatal(err) }
if !applied.Applied || applied.Readiness == nil || applied.Readiness.CanReceiveRequestsNow { t.Fatalf("configuration must not claim shift coverage: %+v", applied) }
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
assertSetupCount(t, db, &models.Channel{}, 0)
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
second := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
if len(second.Changes) != 0 { t.Fatalf("existing configuration must be preserved: %+v", second.Changes) }
}
func TestSetupReadiness_PreviewInvalidatedByConfigurationChange(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatal(err) }
if err := db.Create(&models.AgentTeam{Name: "Deliberately chosen team", Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
req.Apply, req.PreviewToken = true, preview.PreviewToken
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("stale preview must fail") }
assertSetupCount(t, db, &models.AgentTeam{}, 1)
assertSetupCount(t, db, &models.AgentProfile{}, 0)
assertSetupCount(t, db, &models.AIAgent{}, 0)
assertSetupCount(t, db, &models.Channel{}, 0)
}
func TestSetupReadiness_RepairTransactionRollsBackAllWrites(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatal(err) }
if err := db.Callback().Create().Before("gorm:create").Register("fail_setup_agent", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "AIAgent" { tx.AddError(errors.New("injected agent write failure")) }
}); err != nil { t.Fatal(err) }
req.Apply, req.PreviewToken = true, preview.PreviewToken
if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatal("expected injected transaction failure") }
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}, &models.Channel{}} { assertSetupCount(t, db, model, 0) }
}
func TestSetupReadiness_PreservesDeletedDisabledAndZeroRollout(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
team := models.AgentTeam{Name: "Deleted team", Status: enums.StatusDeleted}
disabledTeam := models.AgentTeam{Name: "Disabled team", Status: enums.StatusDisabled}
profile := models.AgentProfile{UserID: principal.UserID, AgentCode: "deleted-profile", Status: enums.StatusDeleted}
deletedAgent := models.AIAgent{Name: "Deleted agent", Status: enums.StatusDeleted}
disabledAgent := models.AIAgent{Name: "Disabled agent", Status: enums.StatusDisabled, RolloutPercent: 100}
zeroAgent := models.AIAgent{Name: "Zero rollout", Status: enums.StatusOk, RolloutPercent: 100}
deletedChannel := models.Channel{Name: "Deleted channel", ChannelID: "deleted-channel", Status: enums.StatusDeleted}
disabledChannel := models.Channel{Name: "Disabled channel", ChannelID: "disabled-channel", Status: enums.StatusDisabled, AIAgentRolloutPercent: 100}
for _, model := range []any{&team, &disabledTeam, &profile, &deletedAgent, &disabledAgent, &zeroAgent, &deletedChannel, &disabledChannel} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
if err := db.Model(&zeroAgent).Update("rollout_percent", 0).Error; err != nil { t.Fatal(err) }
if err := db.Model(&disabledChannel).Update("ai_agent_rollout_percent", 0).Error; err != nil { t.Fatal(err) }
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
if len(result.Changes) != 0 { t.Fatalf("broad repair must preserve intentional configurations: %+v", result.Changes) }
for _, req := range []request.SetupQuickFixRequest{
{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID},
{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: deletedAgent.ID},
{Action: services.QuickFixActionEnsureDefaultChannel, EntityID: deletedChannel.ID},
{Action: services.QuickFixActionEnableMyProfile},
} { if _, err := services.SetupReadinessService.ApplyQuickFix(req, principal); err == nil { t.Fatalf("deleted target must not be restored: %+v", req) } }
for _, id := range []int64{disabledAgent.ID, zeroAgent.ID} {
res := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: id}, principal)
if len(res.Changes) != 0 { t.Fatalf("disabled/zero rollout changed: %+v", res) }
}
if err := db.First(&zeroAgent, zeroAgent.ID).Error; err != nil { t.Fatal(err) }
if zeroAgent.RolloutPercent != 0 { t.Fatal("zero rollout was overwritten") }
if err := db.First(&disabledChannel, disabledChannel.ID).Error; err != nil { t.Fatal(err) }
if disabledChannel.Status != enums.StatusDisabled || disabledChannel.AIAgentRolloutPercent != 0 { t.Fatal("disabled channel was overwritten") }
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
}
func TestSetupReadiness_TargetedHandoffDoesNotRewriteOtherAgents(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
first := models.AIAgent{Name: "Selected", Status: enums.StatusOk, RolloutPercent: 25, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "12"}
second := models.AIAgent{Name: "Unrelated", Status: enums.StatusOk, RolloutPercent: 10, ServiceMode: enums.IMConversationServiceModeAIOnly, TeamIDs: "23"}
for _, agent := range []*models.AIAgent{&first, &second} {
if err := db.Create(agent).Error; err != nil { t.Fatal(err) }
if err := db.Model(agent).Update("auto_handoff_on_failure", false).Error; err != nil { t.Fatal(err) }
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff}, principal); err == nil { t.Fatal("existing agents require a named target") }
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionFixAIAgentHandoff, EntityID: first.ID}, principal)
if len(result.Changes) != 1 || result.Changes[0].EntityID != first.ID { t.Fatalf("wrong scope: %+v", result.Changes) }
if err := db.First(&first, first.ID).Error; err != nil { t.Fatal(err) }
if err := db.First(&second, second.ID).Error; err != nil { t.Fatal(err) }
if first.ServiceMode != enums.IMConversationServiceModeAIFirst || first.RolloutPercent != 25 || first.TeamIDs != "12" { t.Fatal("selected agent was not minimally updated") }
if second.ServiceMode != enums.IMConversationServiceModeAIOnly || second.AutoHandoffOnFailure || second.RolloutPercent != 10 || second.TeamIDs != "23" { t.Fatal("unrelated agent was changed") }
}
func TestSetupReadiness_OffHoursIsNotMissingSchedule(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
now := time.Now()
team := models.AgentTeam{Name: "Day shift", Status: enums.StatusOk}
other := models.AgentTeam{Name: "Other team", Status: enums.StatusOk}
for _, model := range []any{&team, &other} { if err := db.Create(model).Error; err != nil { t.Fatal(err) } }
schedule := models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(24*time.Hour), EndAt: now.Add(32*time.Hour), Status: enums.StatusOk}
if err := db.Create(&schedule).Error; err != nil { t.Fatal(err) }
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureTeamSchedule, EntityID: team.ID}, principal)
if len(result.Changes) != 0 { t.Fatal("off-hours team must not be mutated") }
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 1)
if result.Readiness == nil { t.Fatal("missing readiness") }
foundOffHours, foundMissing := false, false
for _, step := range result.Readiness.Steps {
if step.Key != "schedules" { continue }
for _, issue := range step.Issues {
if issue.QuickFixAction != "" {
t.Fatalf("expected issue %s QuickFixAction to be empty for cs_user without write permissions, got %s", issue.Code, issue.QuickFixAction)
}
if issue.EntityID == team.ID && issue.Code == "team_off_hours_now" { foundOffHours = true }
if issue.EntityID == other.ID && issue.Code == "team_no_schedule_configured" { foundMissing = true }
if issue.QuickFixAction != "" { t.Fatal("shift changes require explicit scheduling") }
}
}
// 2. Direct API calls to ApplyQuickFix must fail with Forbidden (error.e0225) and leave DB untouched
for _, action := range []string{
services.QuickFixActionQuickSetupAll,
services.QuickFixActionEnsureTeamSchedule,
services.QuickFixActionEnableMyProfile,
services.QuickFixActionFixAIAgentHandoff,
services.QuickFixActionEnsureDefaultChannel,
} {
_, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: action,
}, csPrincipal)
if err == nil {
t.Fatalf("expected Forbidden error when cs_user executes %s without permissions, got nil", action)
}
}
var teamCount, agentCount, channelCount, profileCount int64
db.Model(&models.AgentTeam{}).Count(&teamCount)
db.Model(&models.AIAgent{}).Count(&agentCount)
db.Model(&models.Channel{}).Count(&channelCount)
db.Model(&models.AgentProfile{}).Count(&profileCount)
if teamCount != 0 || agentCount != 0 || channelCount != 0 || profileCount != 0 {
t.Fatalf("expected zero entities created after forbidden quick fixes, got teams=%d aiAgents=%d channels=%d profiles=%d",
teamCount, agentCount, channelCount, profileCount)
}
// 3. Seed an active team + active schedule + busy profile for csUser
now := time.Now()
team := models.AgentTeam{Name: "Main Team", Status: enums.StatusOk}
db.Create(&team)
db.Create(&models.AgentTeamSchedule{
TeamID: team.ID,
StartAt: now.Add(-1 * time.Hour),
EndAt: now.Add(24 * time.Hour),
Status: enums.StatusOk,
})
db.Create(&models.AgentProfile{
UserID: csUser.ID,
TeamID: team.ID,
AgentCode: "AG-CS1",
DisplayName: "Support Agent",
ServiceStatus: enums.ServiceStatusBusy,
MaxConcurrentCount: 5,
AutoAssignEnabled: true,
Status: enums.StatusOk,
})
// Grant only agent.updateStatus to csPrincipal: can switch own existing profile to Idle,
// but still cannot modify AI agents or channels!
statusOnlyPrincipal := &dto.AuthPrincipal{
UserID: csUser.ID,
Username: csUser.Username,
Nickname: csUser.Nickname,
Roles: []string{constants.RoleCodeCsUser},
Permissions: []string{
constants.PermissionAgentView.Code,
constants.PermissionAgentUpdateStatus.Code,
},
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionFixAIAgentHandoff,
}, statusOnlyPrincipal); err == nil {
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls fix_ai_agent_handoff")
}
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionEnsureDefaultChannel,
}, statusOnlyPrincipal); err == nil {
t.Fatalf("expected Forbidden when statusOnlyPrincipal calls ensure_default_channel")
}
fixProfileResp, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{
Action: services.QuickFixActionEnableMyProfile,
}, statusOnlyPrincipal)
if err != nil {
t.Fatalf("expected statusOnlyPrincipal to succeed on enable_my_operator_profile when team+schedule+profile exist, got %v", err)
}
if !fixProfileResp.Readiness.CurrentOperator.CanReceiveNow {
t.Fatalf("expected CurrentOperator.CanReceiveNow = true after status update, blockers = %v", fixProfileResp.Readiness.CurrentOperator.BlockerCodes)
}
if !foundOffHours || !foundMissing { t.Fatal("missing and off-hours schedules must be distinguished") }
}
func TestSetupReadiness_AdminRoleDoesNotOverridePermissionDenial(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
principal.Permissions = nil
for _, role := range []string{constants.RoleCodeAdmin, constants.RoleCodeSuperAdmin} {
principal.Roles = []string{role}
for _, action := range []string{services.QuickFixActionQuickSetupAll, services.QuickFixActionEnsureTeamSchedule, services.QuickFixActionEnableMyProfile, services.QuickFixActionFixAIAgentHandoff, services.QuickFixActionEnsureDefaultChannel} {
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: action}, principal); err == nil { t.Fatalf("role %s bypassed explicit permission list for %s", role, action) }
}
}
assertSetupCount(t, db, &models.AgentTeam{}, 0)
}
func TestSetupReadiness_StatusOnlyPermissionRemainsScoped(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
team := models.AgentTeam{Name: "Existing", Status: enums.StatusOk}
if err := db.Create(&team).Error; err != nil { t.Fatal(err) }
profile := models.AgentProfile{UserID: principal.UserID, TeamID: team.ID, AgentCode: "status-only", Status: enums.StatusOk, ServiceStatus: enums.ServiceStatusBusy, AutoAssignEnabled: true, MaxConcurrentCount: 5}
if err := db.Create(&profile).Error; err != nil { t.Fatal(err) }
principal.Permissions = []string{constants.PermissionAgentUpdateStatus.Code}
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal)
if len(result.Changes) != 1 || len(result.Changes[0].After) != 1 { t.Fatalf("status-only permission exceeded: %+v", result.Changes) }
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
if err := db.Model(&profile).Update("auto_assign_enabled", false).Error; err != nil { t.Fatal(err) }
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile}, principal); err == nil { t.Fatal("status permission must not authorize config edits") }
}
func TestSetupReadiness_SimulationEmptyArraysAndHonestCoverage(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
for _, channelID := range []int64{0, 9999} {
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channelID}, principal)
if err != nil { t.Fatal(err) }
encoded, err := json.Marshal(result)
if err != nil { t.Fatal(err) }
if !strings.Contains(string(encoded), `"candidate_operators":[]`) { t.Fatalf("null candidates: %s", encoded) }
}
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
var team models.AgentTeam
var agent models.AIAgent
if err := db.First(&team).Error; err != nil { t.Fatal(err) }
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
channel := models.Channel{Name: "Published human-only test route", ChannelID: "human-only-test", ChannelType: "web", AIAgentID: agent.ID, AIAgentRolloutPercent: 100, Status: enums.StatusOk}
if err := db.Create(&channel).Error; err != nil { t.Fatal(err) }
now := time.Now()
if err := db.Create(&models.AgentTeamSchedule{TeamID: team.ID, StartAt: now.Add(-time.Hour), EndAt: now.Add(time.Hour), Status: enums.StatusOk}).Error; err != nil { t.Fatal(err) }
result, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil { t.Fatal(err) }
if !result.WillReachOperator || result.MatchedOperator == nil || result.MatchedOperator.UserID != principal.UserID { t.Fatalf("human-only check failed: %+v", result) }
if _, err := services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID, BypassAI: true}, principal); err == nil { t.Fatal("unsupported bypass must be explicitly rejected") }
if err := db.Model(&models.AIAgent{}).Where("id = ?", channel.AIAgentID).Update("service_mode", enums.IMConversationServiceModeAIFirst).Error; err != nil { t.Fatal(err) }
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil { t.Fatal(err) }
if result.WillReachOperator || result.FinalOutcomeCode != "ai_execution_not_simulated" { t.Fatalf("AI route must not promise assignment: %+v", result) }
if err := db.Model(&channel).Update("ai_agent_rollout_percent", 25).Error; err != nil { t.Fatal(err) }
result, err = services.SetupReadinessService.SimulateRoute(request.SetupSimulateRouteRequest{ChannelID: channel.ID}, principal)
if err != nil { t.Fatal(err) }
if result.WillReachOperator || result.FinalOutcomeCode != "partial_rollout_not_simulated" { t.Fatalf("partial rollout must not promise assignment: %+v", result) }
}
func TestSetupReadiness_PostCommitFailuresAreVisible(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
req := request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}
preview, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatal(err) }
if err := db.Callback().Query().Before("gorm:query").Register("fail_post_commit_dispatch", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "Conversation" { tx.AddError(errors.New("injected dispatch read failure")) }
}); err != nil { t.Fatal(err) }
req.Apply, req.PreviewToken = true, preview.PreviewToken
result, err := services.SetupReadinessService.ApplyQuickFix(req, principal)
if err != nil { t.Fatalf("committed repair must not appear rolled back: %v", err) }
if !result.Applied || !slices.Contains(result.WarningCodes, "dispatch_failed") { t.Fatalf("dispatch failure must be visible: %+v", result) }
for _, model := range []any{&models.AgentTeam{}, &models.AgentProfile{}, &models.AIAgent{}} { assertSetupCount(t, db, model, 1) }
assertSetupCount(t, db, &models.Channel{}, 0)
}
func TestSetupReadiness_DefaultChannelRequiresPublishedAgent(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
result := applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
if len(result.Changes) != 0 || !slices.Contains(result.WarningCodes, "agent_publication_required") { t.Fatalf("draft agent must not be bound implicitly: %+v", result) }
assertSetupCount(t, db, &models.Channel{}, 0)
var agent models.AIAgent
if err := db.First(&agent).Error; err != nil { t.Fatal(err) }
// This unit fixture supplies the publication marker; revision publication itself
// belongs to the existing agent publishing service's tests.
if err := db.Model(&agent).Update("published_revision_id", 1).Error; err != nil { t.Fatal(err) }
result = applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionEnsureDefaultChannel}, principal)
if len(result.Changes) != 1 || result.Changes[0].EntityType != "channel" { t.Fatalf("expected reviewed channel create: %+v", result.Changes) }
assertSetupCount(t, db, &models.Channel{}, 1)
assertSetupCount(t, db, &models.AgentTeamSchedule{}, 0)
}
func TestSetupReadiness_MyProfileRejectsAnotherNamedTarget(t *testing.T) {
db := setupSetupReadinessTestDB(t)
principal := setupRepairPrincipal(t, db)
applyReviewedSetupFix(t, request.SetupQuickFixRequest{Action: services.QuickFixActionQuickSetupAll}, principal)
var profile models.AgentProfile
if err := db.Where("user_id = ?", principal.UserID).First(&profile).Error; err != nil { t.Fatal(err) }
if _, err := services.SetupReadinessService.ApplyQuickFix(request.SetupQuickFixRequest{Action: services.QuickFixActionEnableMyProfile, EntityID: profile.ID + 1}, principal); err == nil { t.Fatal("my-profile action must not silently substitute a different named target") }
}

View File

@@ -0,0 +1,184 @@
package services
import (
"slices"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/constants"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func newWsAuthFixture(t *testing.T, hasConversationView bool) *authSessionFixture {
t.Helper()
f := newAuthSessionFixture(t)
f.permission.Code = constants.PermissionConversationView.Code
if err := f.db.Model(&f.permission).Update("code", f.permission.Code).Error; err != nil {
t.Fatal(err)
}
permissions := []string{f.permission.Code}
if !hasConversationView {
if err := f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error; err != nil {
t.Fatal(err)
}
permissions = nil
}
f.principal = buildAuthPrincipal(&f.user, &f.session, []string{f.role.Code}, permissions)
previousDB := sqls.DB()
sqls.SetDB(f.db)
t.Cleanup(func() { sqls.SetDB(previousDB) })
return f
}
func newWsAuthClient(f *authSessionFixture, id, role string) *ClientSession {
return &ClientSession{
ID: id,
Role: role,
Principal: f.principal,
Topics: make(map[string]struct{}),
Send: make(chan []byte, 16),
}
}
func TestWsStaffWithoutViewPermissionHasNoConversationDefaults(t *testing.T) {
f := newWsAuthFixture(t, false)
ws := newWsService()
session := newWsAuthClient(f, "no-view", realtimeRoleAdmin)
if !ws.revalidateSession(session) {
t.Fatal("expected a valid authenticated staff session")
}
if topics := ws.defaultTopics(session); len(topics) != 0 {
t.Fatalf("staff without view permission received default topics: %v", topics)
}
if topics := ws.filterAllowedTopics(session, []string{realtimeTopicAdminAll, ws.adminTopic(f.user.ID), "conversation:100"}); len(topics) != 0 {
t.Fatalf("staff without view permission could subscribe: %v", topics)
}
}
func TestWsNotificationSessionOnlyReceivesOwnNotificationTopic(t *testing.T) {
for _, hasView := range []bool{false, true} {
name := "without view permission"
if hasView {
name = "with view permission"
}
t.Run(name, func(t *testing.T) {
f := newWsAuthFixture(t, hasView)
ws := newWsService()
session := newWsAuthClient(f, "notification", realtimeRoleNotification)
ownTopic := ws.notificationTopic(f.user.ID)
if got := ws.defaultTopics(session); !slices.Equal(got, []string{ownTopic}) {
t.Fatalf("unexpected notification defaults: %v", got)
}
requested := []string{ownTopic, ws.notificationTopic(f.user.ID + 1), realtimeTopicAdminAll, "conversation:100"}
if got := ws.filterAllowedTopics(session, requested); !slices.Equal(got, []string{ownTopic}) {
t.Fatalf("notification connection escaped its own topic: %v", got)
}
// Simulate stale or incorrectly registered membership. Delivery must
// still reject conversation events, including when view is granted.
ws.manager.Register(session, requested)
ws.PublishToTopic("conversation:100", ws.newEvent("conversation:100", RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
if len(session.Send) != 0 {
t.Fatal("notification connection received a conversation event")
}
ws.PublishToTopic(ownTopic, ws.newEvent(ownTopic, RealtimeNotificationCreatedEvent{}))
if len(session.Send) != 1 || session.Closed.Load() {
t.Fatal("valid notification connection did not receive its own notification")
}
})
}
}
func TestWsStaleAdminAllMembershipDoesNotGrantDelivery(t *testing.T) {
f := newWsAuthFixture(t, false)
ws := newWsService()
session := newWsAuthClient(f, "stale-member", realtimeRoleAdmin)
ws.manager.Register(session, []string{realtimeTopicAdminAll})
ws.PublishToTopic(realtimeTopicAdminAll, ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated}))
if len(session.Send) != 0 {
t.Fatal("stale admin:all membership bypassed conversation-view permission")
}
}
func TestWsStaffRevocationClosesBeforeDeliveryOrIncomingMessage(t *testing.T) {
cases := []struct {
name string
change func(*authSessionFixture) error
}{
{"revoked login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("revoked_at", time.Now()).Error }},
{"expired login", func(f *authSessionFixture) error { return f.db.Model(&f.session).Update("expired_at", time.Now().Add(-time.Second)).Error }},
{"disabled user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("status", enums.StatusDisabled).Error }},
{"downgraded user type", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("user_type", enums.UserTypeUser).Error }},
{"deleted user", func(f *authSessionFixture) error { return f.db.Model(&f.user).Update("deleted_at", time.Now()).Error }},
{"removed role", func(f *authSessionFixture) error { return f.db.Where("user_id = ?", f.user.ID).Delete(&models.UserRole{}).Error }},
{"disabled role", func(f *authSessionFixture) error { return f.db.Model(&f.role).Update("status", enums.StatusDisabled).Error }},
{"removed permission", func(f *authSessionFixture) error { return f.db.Where("role_id = ?", f.role.ID).Delete(&models.RolePermission{}).Error }},
{"disabled permission", func(f *authSessionFixture) error { return f.db.Model(&f.permission).Update("status", enums.StatusDisabled).Error }},
}
for _, tc := range cases {
for _, trigger := range []string{"fanout", "incoming"} {
t.Run(tc.name+"/"+trigger, func(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := newWsAuthClient(f, "staff", realtimeRoleAdmin)
ws.manager.Register(session, ws.defaultTopics(session))
if !ws.revalidateSession(session) {
t.Fatal("expected valid staff session before access change")
}
if err := tc.change(f); err != nil {
t.Fatal(err)
}
event := ws.newEvent(realtimeTopicAdminAll, RealtimeConversationChangedEvent{Type: enums.IMRealtimeEventConversationUpdated})
if trigger == "fanout" {
ws.PublishToTopic(realtimeTopicAdminAll, event)
} else {
ws.handleClientMessage(session, []byte(`{"type":"ping"}`))
}
if !session.Closed.Load() || ws.manager.HasTopic(realtimeTopicAdminAll) {
t.Fatal("stale staff connection remained registered")
}
if len(session.Send) != 0 {
t.Fatal("stale staff connection received an event or pong")
}
ws.handleClientMessage(session, []byte(`{"type":"subscribe","topics":["admin:all","conversation:100"]}`))
ws.PublishToTopic(realtimeTopicAdminAll, event)
if len(session.Send) != 0 || len(ws.manager.TopicList(session)) != 0 {
t.Fatal("closed connection processed later messages or delivery")
}
})
}
}
}
// Existing realtime integration tests also need real login-session bindings now
// that delivery revalidates authorization instead of trusting topic membership.
func seedRealtimeStaffPrincipal(t *testing.T, db *gorm.DB) *dto.AuthPrincipal {
t.Helper()
if err := db.AutoMigrate(&models.Role{}, &models.Permission{}, &models.UserRole{}, &models.RolePermission{}, &models.UserPermission{}, &models.LoginSession{}); err != nil {
t.Fatal(err)
}
user := models.User{ID: 1001, Username: "realtime-observer", UserType: enums.UserTypeEmployee, Status: enums.StatusOk}
role := models.Role{Code: "realtime-observer", Status: enums.StatusOk}
permission := models.Permission{Code: constants.PermissionConversationView.Code, Status: enums.StatusOk}
for _, item := range []any{&user, &role, &permission} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
session := models.LoginSession{UserID: user.ID, Token: "ak_test_only_realtime", ExpiredAt: time.Now().Add(time.Hour)}
for _, item := range []any{
&session,
&models.UserRole{UserID: user.ID, RoleID: role.ID},
&models.RolePermission{RoleID: role.ID, PermissionID: permission.ID},
} {
if err := db.Create(item).Error; err != nil {
t.Fatal(err)
}
}
return buildAuthPrincipal(&user, &session, []string{role.Code}, []string{permission.Code})
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import "sync"
@@ -23,6 +25,7 @@ func (m *WsConnectionManager) Register(session *ClientSession, defaultTopics []s
m.mu.Lock()
defer m.mu.Unlock()
if session.Closed.Load() { return len(m.sessions) }
m.sessions[session.ID] = session
for _, topic := range defaultTopics {
m.subscribeLocked(session, topic)
@@ -53,6 +56,7 @@ func (m *WsConnectionManager) Subscribe(session *ClientSession, topics []string)
m.mu.Lock()
defer m.mu.Unlock()
if session.Closed.Load() { return nil }
ret := make([]string, 0, len(topics))
for _, topic := range topics {
if _, exists := session.Topics[topic]; exists {
@@ -132,3 +136,12 @@ func (m *WsConnectionManager) unsubscribeLocked(session *ClientSession, topic st
}
delete(session.Topics, topic)
}
func (m *WsConnectionManager) TopicList(session *ClientSession) []string {
m.mu.RLock()
defer m.mu.RUnlock()
if session == nil { return nil }
topics := make([]string, 0, len(session.Topics))
for topic := range session.Topics { topics = append(topics, topic) }
return topics
}

View File

@@ -0,0 +1,104 @@
package services
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-desk/internal/pkg/enums"
"github.com/gorilla/websocket"
)
// The listener exists only inside the test. No app server or external service is
// involved; the DB fixture is temporary and every pump is joined before cleanup.
func newSecuritySocketPair(t *testing.T) (*websocket.Conn,*websocket.Conn) {
t.Helper()
accepted:=make(chan *websocket.Conn,1)
failed:=make(chan error,1)
server:=httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
upgrader:=websocket.Upgrader{}
conn,err:=upgrader.Upgrade(w,r,nil)
if err!=nil{failed<-err;return}
accepted<-conn
}))
t.Cleanup(server.Close)
client,_,err:=websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL,"http"),nil)
if err!=nil{t.Fatal(err)}
var serverConn *websocket.Conn
select{
case serverConn=<-accepted:
case err:=<-failed:t.Fatal(err)
case <-time.After(time.Second):t.Fatal("test websocket upgrade timed out")
}
t.Cleanup(func(){_ = client.Close();_ = serverConn.Close()})
return serverConn,client
}
func startSecurityPumps(t *testing.T,ws *wsService,session *ClientSession,reader bool) {
t.Helper()
written:=make(chan struct{})
go func(){defer close(written);ws.writePump(session)}()
var read chan struct{}
if reader{
read=make(chan struct{})
go func(){defer close(read);ws.readPump(session)}()
}
t.Cleanup(func(){
ws.closeSession(session)
select{case <-written:case <-time.After(time.Second):t.Error("write pump did not stop")}
if read!=nil{select{case <-read:case <-time.After(time.Second):t.Error("read pump did not stop")}}
})
}
func requireSecuritySocketClosed(t *testing.T,client *websocket.Conn) {
t.Helper()
_ = client.SetReadDeadline(time.Now().Add(time.Second))
_,_,err:=client.ReadMessage()
if err==nil{t.Fatal("revoked connection delivered a buffered message")}
if networkErr,ok:=err.(interface{Timeout()bool});ok && networkErr.Timeout(){t.Fatal("revoked connection stayed open until test deadline")}
}
func TestWsWritePumpRejectsBufferedEventAfterRevocation(t *testing.T) {
f:=newWsAuthFixture(t,true)
ws:=newWsService()
session:=newWsAuthClient(f,"buffered",realtimeRoleAdmin)
serverConn,client:=newSecuritySocketPair(t)
session.Conn=serverConn
ws.manager.Register(session,ws.defaultTopics(session))
session.enqueueEvent(ws.newEvent(realtimeTopicAdminAll,RealtimeConversationChangedEvent{Type:enums.IMRealtimeEventConversationUpdated}))
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
startSecurityPumps(t,ws,session,false)
requireSecuritySocketClosed(t,client)
if !session.Closed.Load(){t.Fatal("writer did not revoke session")}
}
func TestWsReadPumpRejectsIncomingFrameAfterRevocation(t *testing.T) {
f:=newWsAuthFixture(t,true)
ws:=newWsService()
session:=newWsAuthClient(f,"incoming",realtimeRoleAdmin)
serverConn,client:=newSecuritySocketPair(t)
session.Conn=serverConn
ws.manager.Register(session,ws.defaultTopics(session))
startSecurityPumps(t,ws,session,true)
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
if err:=client.WriteMessage(websocket.TextMessage,[]byte(`{"type":"ping"}`));err!=nil{t.Fatal(err)}
requireSecuritySocketClosed(t,client)
if !session.Closed.Load(){t.Fatal("reader did not revoke session")}
}
func TestWsIdlePumpClosesRevokedConnection(t *testing.T) {
f:=newWsAuthFixture(t,true)
ws:=newWsService()
// Same production timer path, shortened only on this isolated service instance.
ws.authCheckPeriod=10*time.Millisecond
session:=newWsAuthClient(f,"idle",realtimeRoleAdmin)
serverConn,client:=newSecuritySocketPair(t)
session.Conn=serverConn
ws.manager.Register(session,ws.defaultTopics(session))
startSecurityPumps(t,ws,session,false)
if err:=f.db.Model(&f.session).Update("revoked_at",time.Now()).Error;err!=nil{t.Fatal(err)}
requireSecuritySocketClosed(t,client)
if !session.Closed.Load(){t.Fatal("idle authorization timer did not revoke session")}
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -17,6 +19,7 @@ const (
realtimeWriteWait = 10 * time.Second
realtimePongWait = 60 * time.Second
realtimePingPeriod = 30 * time.Second
realtimeAuthCheckPeriod = 5 * time.Second
realtimeMaxMessageSize = 8 << 10
realtimeSendBufferSize = 64
)
@@ -28,8 +31,7 @@ const (
)
const (
realtimeTopicUserPrefix = "user:"
realtimeTopicGuestPrefix = "guest:"
realtimeTopicCustomerPrefix = "customer:"
realtimeTopicAdminPrefix = "admin:"
realtimeTopicConversationPrefix = "conversation:"
realtimeTopicNotificationPrefix = "notification:"
@@ -315,6 +317,8 @@ type ClientSession struct {
Conn *websocket.Conn
Principal *dto.AuthPrincipal
External *openidentity.ExternalUser
CustomerSession *CustomerSessionVerifyResult
sendMu sync.RWMutex
Role string
TerminalType string
Topics map[string]struct{}
@@ -325,9 +329,10 @@ type ClientSession struct {
}
func (s *ClientSession) enqueue(payload []byte) bool {
if s == nil || s.Closed.Load() {
return false
}
if s == nil { return false }
s.sendMu.RLock()
defer s.sendMu.RUnlock()
if s.Closed.Load() { return false }
select {
case s.Send <- payload:
return true
@@ -350,14 +355,3 @@ func (s *ClientSession) touch() {
}
s.LastActiveAt.Store(time.Now().Unix())
}
func (s *ClientSession) topicList() []string {
if s == nil {
return nil
}
ret := make([]string, 0, len(s.Topics))
for topic := range s.Topics {
ret = append(ret, topic)
}
return ret
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -31,6 +33,7 @@ type wsService struct {
upgrader websocket.Upgrader
seq atomic.Uint64
manager *WsConnectionManager
authCheckPeriod time.Duration
}
func newWsService() *wsService {
@@ -41,6 +44,7 @@ func newWsService() *wsService {
},
},
manager: newWsConnectionManager(),
authCheckPeriod: realtimeAuthCheckPeriod,
}
}
@@ -50,6 +54,10 @@ func (s *wsService) HandleDashboardWS(ctx *gin.Context) {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonErrorCode(errorsx.CodeAuthUnauthorized, i18nx.T(ctx, "error.auth.expired")))
return
}
if !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonErrorCode(errorsx.CodeAuthForbidden, i18nx.T(ctx, "error.e0225")))
return
}
if err := s.upgradeConnection(ctx, principal, nil, realtimeRoleAdmin); err != nil {
slog.Error("upgrade admin websocket failed", "error", err, "path", ctx.Request.URL.Path)
ctx.Abort()
@@ -77,29 +85,35 @@ func (s *wsService) HandleOpenWS(ctx *gin.Context) {
return
}
var (
principal = AuthService.GetAuthPrincipal(ctx)
external *openidentity.ExternalUser
customerSessionInfo *CustomerSessionVerifyResult
)
if principal == nil {
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
external = result.ExternalUser
customerSessionInfo = result
}
if err := s.upgradeConnection(ctx, principal, external, realtimeRoleUser, customerSessionInfo); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channelId", channel.ChannelID, "channel_id", channel.ID)
ctx.Abort()
return
}
// The customer endpoint always authenticates the customer credential. A staff
// cookie/bearer must not turn this into a second dashboard subscription path.
result, err := CustomerSessionService.VerifyRequest(ctx, channel)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return
}
if err := s.upgradeConnection(ctx, nil, result.ExternalUser, realtimeRoleUser, result); err != nil {
slog.Error("upgrade open im websocket failed", "error", err, "path", ctx.Request.URL.Path, "channel_id", channel.ID)
ctx.Abort()
return
}
}
func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrincipal, external *openidentity.ExternalUser, role string, customerSessionInfo ...*CustomerSessionVerifyResult) error {
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if principal != nil {
fresh, err := AuthService.RevalidatePrincipal(principal)
if err != nil {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, web.JsonError(err))
return err
}
principal = fresh
if role == realtimeRoleAdmin && !slices.Contains(principal.Permissions, constants.PermissionConversationView.Code) {
err := errorsx.ForbiddenI18n("error.e0225")
ctx.AbortWithStatusJSON(http.StatusForbidden, web.JsonError(err))
return err
}
}
conn, err := s.upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
return err
}
@@ -114,9 +128,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
Topics: make(map[string]struct{}),
Send: make(chan []byte, realtimeSendBufferSize),
}
session.touch()
if len(customerSessionInfo) > 0 { session.CustomerSession = customerSessionInfo[0] }
session.touch()
conn.SetReadLimit(realtimeMaxMessageSize)
conn.SetReadLimit(realtimeMaxMessageSize)
_ = conn.SetReadDeadline(time.Now().Add(realtimePongWait))
conn.SetPongHandler(func(string) error {
session.touch()
@@ -139,13 +154,10 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
"userId", logUserID,
"externalId", logExternalID,
"terminalType", session.TerminalType,
"topicCount", len(session.Topics),
"topicCount", len(s.manager.TopicList(session)),
"sessionCount", sessionCount,
)
go s.writePump(session)
go s.readPump(session)
session.enqueueEvent(s.newEvent("", RealtimeConnectedEvent{
Payload: RealtimeConnectedPayload{
ConnID: session.ID,
@@ -153,7 +165,7 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
GuestID: logExternalID,
Role: role,
TerminalType: session.TerminalType,
Topics: session.topicList(),
Topics: s.manager.TopicList(session),
},
}))
if len(customerSessionInfo) > 0 && customerSessionInfo[0] != nil && customerSessionInfo[0].Refreshed {
@@ -164,6 +176,8 @@ func (s *wsService) upgradeConnection(ctx *gin.Context, principal *dto.AuthPrinc
},
}))
}
go s.writePump(session)
go s.readPump(session)
return nil
}
@@ -181,6 +195,7 @@ func (s *wsService) readPump(session *ClientSession) {
}
func (s *wsService) handleClientMessage(session *ClientSession, payload []byte) {
if !s.revalidateSession(session) { return }
input := realtimeClientMessage{}
if err := json.Unmarshal(payload, &input); err != nil {
session.enqueueEvent(s.newEvent("", RealtimeResyncRequiredEvent{
@@ -214,7 +229,7 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
"eventId", strings.TrimSpace(input.EventID),
)
case enums.IMRealtimeClientTypePresence:
if input.ConversationID > 0 && session.Principal != nil {
if input.ConversationID > 0 && session.Role == realtimeRoleAdmin && session.Principal != nil && s.canSubscribeConversation(session, input.ConversationID) {
action := strings.TrimSpace(input.Action)
if action == "" {
action = "viewing"
@@ -232,8 +247,10 @@ func (s *wsService) handleClientMessage(session *ClientSession, payload []byte)
func (s *wsService) writePump(session *ClientSession) {
ticker := time.NewTicker(realtimePingPeriod)
authTicker := time.NewTicker(s.authCheckPeriod)
defer func() {
ticker.Stop()
authTicker.Stop()
s.closeSession(session)
}()
@@ -245,9 +262,16 @@ func (s *wsService) writePump(session *ClientSession) {
_ = session.Conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
// Recheck after queueing as well: logout or permission revocation can
// happen while a slow client still has messages buffered.
if !s.revalidateSession(session) { return }
var envelope struct { Topic string `json:"topic"` }
if json.Unmarshal(payload, &envelope) != nil || (envelope.Topic != "" && len(s.filterAllowedTopics(session, []string{envelope.Topic})) == 0) { continue }
if err := session.Conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
case <-authTicker.C:
if !s.revalidateSession(session) { return }
case <-ticker.C:
_ = session.Conn.SetWriteDeadline(time.Now().Add(realtimeWriteWait))
if err := session.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
@@ -262,10 +286,12 @@ func (s *wsService) closeSession(session *ClientSession) {
return
}
session.closeOnce.Do(func() {
session.sendMu.Lock()
session.Closed.Store(true)
if session.Send != nil { close(session.Send) }
session.sendMu.Unlock()
remaining := s.manager.Unregister(session)
close(session.Send)
if session.Conn != nil {
_ = session.Conn.Close()
}
@@ -586,8 +612,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
return
}
for _, session := range targets {
if filter != nil && !filter(session) {
for _, session := range targets {
if !s.revalidateSession(session) || !s.canReceiveEvent(session, normalized, event.Topic) { continue }
if filter != nil && !filter(session) {
continue
}
if session.enqueue(payload) {
@@ -603,12 +630,9 @@ func (s *wsService) PublishToTopicsWithFilter(topics []string, event RealtimeEve
}
}
func (s *wsService) IsGuestOnline(guestID string) bool {
guestID = strings.TrimSpace(guestID)
if guestID == "" {
return false
}
return s.manager.HasTopic(s.guestTopic(guestID))
func (s *wsService) IsCustomerOnline(customerID, channelID int64) bool {
if customerID <= 0 || channelID <= 0 { return false }
return s.manager.HasTopic(s.customerTopic(customerID, channelID))
}
func (s *wsService) routeConversationTopics(conversation *models.Conversation) []string {
@@ -617,8 +641,8 @@ func (s *wsService) routeConversationTopics(conversation *models.Conversation) [
}
topics := []string{s.conversationTopic(conversation.ID)}
if identity := ConversationService.GetConversationExternalIdentity(conversation); identity != nil && strings.TrimSpace(identity.ExternalID) != "" {
topics = append(topics, s.guestTopic(identity.ExternalID))
if conversation.CustomerID > 0 && conversation.ChannelID > 0 {
topics = append(topics, s.customerTopic(conversation.CustomerID, conversation.ChannelID))
}
if conversation.CurrentAssigneeID > 0 {
topics = append(topics, s.adminTopic(conversation.CurrentAssigneeID))
@@ -640,19 +664,15 @@ func (s *wsService) defaultTopics(session *ClientSession) []string {
}
return []string{s.notificationTopic(session.Principal.UserID)}
case realtimeRoleAdmin:
if session.Principal == nil || session.Principal.UserID <= 0 {
return []string{realtimeTopicAdminAll}
if session.Principal == nil || session.Principal.UserID <= 0 || !slices.Contains(session.Principal.Permissions, constants.PermissionConversationView.Code) {
return nil
}
return []string{s.adminTopic(session.Principal.UserID), realtimeTopicAdminAll}
default:
// 开放 IM:仅 External、无 AuthPrincipal 的访客连接必须仍能订阅 guest:{externalId},否则收不到推送。
if session.External != nil && strings.TrimSpace(session.External.ExternalID) != "" {
return []string{s.guestTopic(session.External.ExternalID)}
}
if session.Principal != nil && session.Principal.UserID > 0 {
return []string{s.userTopic(session.Principal.UserID)}
}
return nil
default:
if session.CustomerSession != nil && session.External != nil && session.CustomerSession.CustomerID > 0 && session.CustomerSession.ChannelID > 0 {
return []string{s.customerTopic(session.CustomerSession.CustomerID, session.CustomerSession.ChannelID)}
}
return nil
}
}
@@ -693,7 +713,7 @@ func (s *wsService) filterAllowedTopics(session *ClientSession, topics []string)
}
func (s *wsService) canSubscribeConversation(session *ClientSession, conversationID int64) bool {
if session == nil || conversationID <= 0 {
if session == nil || conversationID <= 0 || session.Role == realtimeRoleNotification {
return false
}
if session.Role == realtimeRoleAdmin {
@@ -751,12 +771,8 @@ func (s *wsService) nextID(prefix string) string {
return fmt.Sprintf("%s_%d_%d", prefix, time.Now().UnixNano(), seq)
}
func (s *wsService) userTopic(userID int64) string {
return realtimeTopicUserPrefix + strconv.FormatInt(userID, 10)
}
func (s *wsService) guestTopic(guestID string) string {
return realtimeTopicGuestPrefix + strings.TrimSpace(guestID)
func (s *wsService) customerTopic(customerID, channelID int64) string {
return realtimeTopicCustomerPrefix + strconv.FormatInt(channelID, 10) + ":" + strconv.FormatInt(customerID, 10)
}
func (s *wsService) adminTopic(userID int64) string {
@@ -817,3 +833,24 @@ func formatWsTime(t *time.Time) string {
}
return t.Format(time.DateTime)
}
// Topic membership is an optimization, never an authorization grant.
func (s *wsService) canReceiveEvent(session *ClientSession, routingTopics []string, eventTopic string) bool {
if eventTopic != "" { return len(s.filterAllowedTopics(session, []string{eventTopic})) > 0 }
return len(s.filterAllowedTopics(session, routingTopics)) > 0
}
// Principal snapshots are immutable. Closing when their scope changes avoids
// races between fanout/read/write goroutines and forces a fresh subscription set.
func (s *wsService) revalidateSession(session *ClientSession) bool {
if session == nil || session.Closed.Load() { return false }
valid := false
if session.Principal != nil {
fresh, err := AuthService.RevalidatePrincipal(session.Principal)
valid = err == nil && fresh.UserType == session.Principal.UserType && slices.Equal(fresh.Roles, session.Principal.Roles) && slices.Equal(fresh.Permissions, session.Principal.Permissions)
} else if session.Role == realtimeRoleUser {
valid = CustomerSessionService.VerifyConnection(session.CustomerSession)
}
if !valid { s.closeSession(session) }
return valid
}

View File

@@ -1,3 +1,5 @@
// Modified in the independent hardening fork; see docs/hardening.
package services
import (
@@ -6,7 +8,6 @@ import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
)
@@ -49,12 +50,9 @@ func TestWsAgentActivityEventType(t *testing.T) {
}
func TestPublishAgentActivity(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
session := &ClientSession{
ID: "test-sess",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
session := newWsAuthClient(f, "test-sess", realtimeRoleAdmin)
ws.manager.Register(session, []string{"conversation:100"})
conv := &models.Conversation{ID: 100}
@@ -100,27 +98,16 @@ func TestWsOperatorPresenceEventType(t *testing.T) {
}
func TestPublishOperatorPresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
subscriber := &ClientSession{
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ws.manager.Register(subscriber, []string{"conversation:100"})
sender := &ClientSession{
ID: "sender-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
ws.manager.Register(sender, []string{"conversation:100"})
principal := &dto.AuthPrincipal{
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
principal := f.principal
ws.PublishOperatorPresence(100, principal, "typing", sender.ID)
@@ -136,7 +123,7 @@ func TestPublishOperatorPresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected type %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type)
}
if event.Data.ConversationID != 100 || event.Data.UserID != 2 || event.Data.Username != "operator" || event.Data.Nickname != "Agent Smith" || event.Data.Action != "typing" {
if event.Data.ConversationID != 100 || event.Data.UserID != principal.UserID || event.Data.Username != principal.Username || event.Data.Nickname != principal.Nickname || event.Data.Action != "typing" {
t.Fatalf("unexpected data: %+v", event.Data)
}
case <-time.After(time.Second):
@@ -151,26 +138,13 @@ func TestPublishOperatorPresence(t *testing.T) {
}
func TestHandleClientMessagePresence(t *testing.T) {
f := newWsAuthFixture(t, true)
ws := newWsService()
subscriber := &ClientSession{
ID: "subscriber-conn",
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
subscriber := newWsAuthClient(f, "subscriber-conn", realtimeRoleAdmin)
ws.manager.Register(subscriber, []string{"conversation:100"})
principal := &dto.AuthPrincipal{
UserID: 2,
Username: "operator",
Nickname: "Agent Smith",
}
sender := &ClientSession{
ID: "sender-conn",
Principal: principal,
Topics: map[string]struct{}{},
Send: make(chan []byte, 10),
}
sender := newWsAuthClient(f, "sender-conn", realtimeRoleAdmin)
ws.manager.Register(sender, []string{"conversation:100"})
msgJSON := []byte(`{"type":"presence","conversationId":100,"action":"viewing"}`)
@@ -188,7 +162,7 @@ func TestHandleClientMessagePresence(t *testing.T) {
if event.Type != enums.IMRealtimeEventOperatorPresence {
t.Fatalf("expected %s, got %s", enums.IMRealtimeEventOperatorPresence, event.Type)
}
if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != 2 {
if event.Data.ConversationID != 100 || event.Data.Action != "viewing" || event.Data.UserID != f.principal.UserID {
t.Fatalf("unexpected data: %+v", event.Data)
}
case <-time.After(time.Second):

View File

@@ -0,0 +1,30 @@
package services
import (
"sync"
"testing"
)
// Run with -race: close may be triggered independently by revocation, a reader,
// a writer, or slow-client eviction while broadcasts still hold a session pointer.
func TestWsConcurrentEnqueueCloseAndSubscribe(t *testing.T) {
ws:=newWsService()
session:=&ClientSession{ID:"concurrent",Topics:map[string]struct{}{},Send:make(chan []byte,64)}
ws.manager.Register(session,[]string{"notification:1"})
var work sync.WaitGroup
for i:=0;i<8;i++ {
work.Add(1)
go func() {
defer work.Done()
for j:=0;j<100;j++ {
session.enqueue([]byte("test"))
ws.manager.Subscribe(session,[]string{"notification:1"})
ws.manager.TopicList(session)
}
}()
}
ws.closeSession(session)
work.Wait()
if !session.Closed.Load() || ws.manager.HasTopic("notification:1") || len(ws.manager.TopicList(session))!=0 { t.Fatal("closed session was re-registered") }
if session.enqueue([]byte("test")) { t.Fatal("closed session accepted a message") }
}