- 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.
182 lines
4.8 KiB
Go
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
|
|
}
|