ultisuite-backend/internal/migration/claim_email_match.go
R3D347HR4Y 7143a36c19
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(mail): integrate Stalwart hosted mail and migration features
- Added configuration options for Stalwart hosted mail in .env.example.
- Updated Docker Compose to include Stalwart service with health checks.
- Introduced new API endpoints for managing mail domains and migration projects.
- Enhanced Authentik blueprints for user enrollment and post-migration security.
- Updated OAuth handling for Google and Microsoft migration processes.
- Improved error handling and response structures in the mail API.
- Added integration tests for email claiming and migration workflows.
2026-06-13 12:47:08 +02:00

151 lines
3.9 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
}
func ClaimIdentityFromAuth(c *auth.Claims) ClaimIdentity {
if c == nil {
return ClaimIdentity{}
}
return ClaimIdentity{
Email: c.Email,
PreferredUsername: c.PreferredUsername,
UPN: c.UPN,
}
}
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 localPartAliasMatch(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
}
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 localPartAliasMatch(target, candidate) {
return true
}
}
}
return projectDomainUPNMatch(inviteEmail, projectDomain, identity)
}