Files
agent-desk-ng/internal/services/customer_identity_security_test.go
mono 63e0457886 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.
2026-10-16 12:15:20 +00:00

273 lines
17 KiB
Go

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")}
}