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.
52 lines
2.6 KiB
Go
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
|
|
}
|