ultisuite-backend/internal/migration/claim_email_match.go
R3D347HR4Y 1ffd0817d8
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(migration): enhance migration API with roster and audit export features
- Added endpoints for listing and importing migration rosters.
- Introduced audit export functionality for migration jobs in CSV and NDJSON formats.
- Implemented tenant mismatch validation for Microsoft migration claims.
- Enhanced error handling for email claiming and migration processes.
- Added integration tests for roster import and claim workflows.
2026-06-13 13:11:30 +02:00

182 lines
4.8 KiB
Go

package migration
import (
"strings"
"github.com/ultisuite/ulti-backend/internal/auth"
)
// ClaimIdentity holds SSO identity fields checked against a migration invite.
type ClaimIdentity struct {
Email string
PreferredUsername string
UPN string
TenantID string
}
func ClaimIdentityFromAuth(c *auth.Claims) ClaimIdentity {
if c == nil {
return ClaimIdentity{}
}
return ClaimIdentity{
Email: c.Email,
PreferredUsername: c.PreferredUsername,
UPN: c.UPN,
TenantID: c.TID,
}
}
func normalizeInviteEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
func isEmailAddress(s string) bool {
s = strings.TrimSpace(s)
at := strings.LastIndex(s, "@")
return at > 0 && at < len(s)-1
}
func identityCandidateEmails(id ClaimIdentity) []string {
seen := make(map[string]struct{})
var out []string
for _, raw := range []string{id.Email, id.PreferredUsername, id.UPN} {
raw = strings.TrimSpace(raw)
if raw == "" || !isEmailAddress(raw) {
continue
}
norm := normalizeInviteEmail(raw)
if _, ok := seen[norm]; ok {
continue
}
seen[norm] = struct{}{}
out = append(out, norm)
}
return out
}
func normalizeEmailLocalPart(local string) string {
local = strings.ToLower(strings.TrimSpace(local))
if plus := strings.Index(local, "+"); plus >= 0 {
local = local[:plus]
}
return strings.ReplaceAll(local, ".", "")
}
func emailLocalAndDomain(email string) (local, domain string, ok bool) {
email = normalizeInviteEmail(email)
at := strings.LastIndex(email, "@")
if at <= 0 || at == len(email)-1 {
return "", "", false
}
return email[:at], email[at+1:], true
}
func inviteMatchTargets(inviteEmail string, alternateEmails []string) []string {
seen := make(map[string]struct{})
var out []string
add := func(e string) {
e = normalizeInviteEmail(e)
if e == "" || !isEmailAddress(e) {
return
}
if _, ok := seen[e]; ok {
return
}
seen[e] = struct{}{}
out = append(out, e)
}
add(inviteEmail)
for _, alt := range alternateEmails {
add(alt)
}
return out
}
func isGmailAliasDomain(domain string) bool {
switch strings.ToLower(strings.TrimSpace(domain)) {
case "gmail.com", "googlemail.com":
return true
default:
return false
}
}
// gmailLocalPartAliasMatch applies Gmail dot/plus normalization only on gmail.com / googlemail.com.
func gmailLocalPartAliasMatch(a, b string) bool {
aLocal, aDomain, okA := emailLocalAndDomain(a)
bLocal, bDomain, okB := emailLocalAndDomain(b)
if !okA || !okB {
return false
}
if !strings.EqualFold(aDomain, bDomain) {
return false
}
if !isGmailAliasDomain(aDomain) {
return false
}
return normalizeEmailLocalPart(aLocal) == normalizeEmailLocalPart(bLocal)
}
// projectDomainUPNMatch accepts claims when the invite is on the hosted project domain
// and a UPN-style identity (preferred_username / upn) shares the same mailbox local-part.
// Typical Microsoft case: invite alice@acme.com, SSO preferred_username alice@tenant.onmicrosoft.com.
func projectDomainUPNMatch(inviteEmail, projectDomain string, identity ClaimIdentity) bool {
if projectDomain == "" {
return false
}
projectDomain = strings.ToLower(strings.TrimSpace(projectDomain))
invLocal, invDomain, ok := emailLocalAndDomain(inviteEmail)
if !ok || !strings.EqualFold(invDomain, projectDomain) {
return false
}
for _, raw := range []string{identity.PreferredUsername, identity.UPN} {
candLocal, _, ok := emailLocalAndDomain(raw)
if ok && strings.EqualFold(candLocal, invLocal) {
return true
}
}
return false
}
// InviteEmailMatchesIdentity reports whether SSO identity may claim the invite.
// projectDomain is the hosted mail domain when the migration project is domain-bound.
func InviteEmailMatchesIdentity(inviteEmail string, alternateEmails []string, projectDomain string, identity ClaimIdentity) bool {
targets := inviteMatchTargets(inviteEmail, alternateEmails)
if len(targets) == 0 {
return false
}
candidates := identityCandidateEmails(identity)
if len(candidates) == 0 {
return false
}
for _, target := range targets {
for _, candidate := range candidates {
if candidate == target {
return true
}
if gmailLocalPartAliasMatch(target, candidate) {
return true
}
}
}
return projectDomainUPNMatch(inviteEmail, projectDomain, identity)
}
// validateMicrosoftTenantClaim rejects claims when the OIDC tid does not match the project's pinned tenant.
func validateMicrosoftTenantClaim(proj Project, tokenTenantID string) error {
if strings.ToLower(strings.TrimSpace(proj.SourceProvider)) != "microsoft" {
return nil
}
expected := strings.TrimSpace(proj.MicrosoftTenantID)
if expected == "" {
return nil
}
got := strings.TrimSpace(tokenTenantID)
if got == "" || !strings.EqualFold(got, expected) {
return ErrTenantMismatch
}
return nil
}