Files
agent-desk-ng/internal/repositories/customer_identity_scope.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

52 lines
2.6 KiB
Go

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
}