1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/worksmobile_mapper.go
2026-05-29 10:33:15 +09:00

669 lines
20 KiB
Go

package service
import (
"baron-sso-backend/internal/domain"
"crypto/rand"
"errors"
"fmt"
"math/big"
"net/mail"
"os"
"sort"
"strconv"
"strings"
)
const (
WorksmobileUserActionUpsert = "UPSERT"
WorksmobileUserActionSuspend = "SUSPEND"
)
type WorksmobileOrgUnitPayload struct {
DomainID int64 `json:"domainId"`
OrgUnitName string `json:"orgUnitName"`
Email string `json:"email,omitempty"`
OrgUnitExternalKey string `json:"orgUnitExternalKey"`
ParentOrgUnitID string `json:"parentOrgUnitId,omitempty"`
DisplayOrder int `json:"displayOrder"`
}
type WorksmobileUserPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email"`
UserExternalKey string `json:"userExternalKey"`
UserName WorksmobileUserName `json:"userName"`
CellPhone string `json:"cellPhone,omitempty"`
EmployeeNumber string `json:"employeeNumber,omitempty"`
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
type WorksmobileUserName struct {
LastName string `json:"lastName,omitempty"`
}
type WorksmobilePasswordConfig struct {
PasswordCreationType string `json:"passwordCreationType"`
Password string `json:"password"`
}
type WorksmobileUserOrganization struct {
DomainID int64 `json:"domainId,omitempty"`
Primary bool `json:"primary,omitempty"`
OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
}
type WorksmobileUserOrgUnit struct {
OrgUnitID string `json:"orgUnitId"`
Primary bool `json:"primary,omitempty"`
PositionID string `json:"positionId,omitempty"`
IsManager *bool `json:"isManager,omitempty"`
}
func BuildWorksmobileOrgUnitPayload(tenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) {
return BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant, tenant, rootConfig, displayOrder)
}
func BuildWorksmobileOrgUnitPayloadForDomainTenant(tenant domain.Tenant, domainTenant domain.Tenant, rootConfig domain.JSONMap, displayOrder int) (WorksmobileOrgUnitPayload, error) {
if err := ValidateWorksmobileExternalKey(tenant.ID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
if displayOrder < 1 {
displayOrder = 1
}
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileOrgUnitPayload{}, err
}
payload := WorksmobileOrgUnitPayload{
DomainID: domainID,
OrgUnitName: strings.TrimSpace(tenant.Name),
Email: buildWorksmobileOrgUnitEmail(tenant, domainTenant),
OrgUnitExternalKey: tenant.ID,
DisplayOrder: displayOrder,
}
if tenant.ParentID != nil && *tenant.ParentID != "" {
if err := ValidateWorksmobileExternalKey(*tenant.ParentID); err != nil {
return WorksmobileOrgUnitPayload{}, err
}
payload.ParentOrgUnitID = "externalKey:" + *tenant.ParentID
}
return payload, nil
}
func buildWorksmobileOrgUnitEmail(tenant domain.Tenant, domainTenant domain.Tenant) string {
slug := strings.ToLower(strings.TrimSpace(tenant.Slug))
if slug == "" {
return ""
}
if domainName := worksmobileTenantMailDomain(domainTenant); domainName != "" {
return slug + "@" + domainName
}
for _, candidate := range append([]domain.TenantDomain{}, domainTenant.Domains...) {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
for _, candidate := range tenant.Domains {
domainName := strings.ToLower(strings.TrimSpace(candidate.Domain))
if domainName != "" {
return slug + "@" + domainName
}
}
return ""
}
func worksmobileTenantMailDomain(tenant domain.Tenant) string {
envKey := strings.TrimSuffix(worksmobileTenantDomainIDEnvKey(tenant), "_DOMAIN_ID")
if domainName := strings.ToLower(strings.TrimSpace(os.Getenv(envKey + "_MAIL_DOMAIN"))); domainName != "" {
return domainName
}
switch envKey {
case "SAMAN":
return "samaneng.com"
case "HANMAC":
return "hanmaceng.co.kr"
case "GPDTDC":
return "baroncs.co.kr"
case "BARONGROUP":
return "brsw.kr"
default:
return ""
}
}
func BuildWorksmobileUserPayload(user domain.User, tenant domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenant(user, tenant, tenant, rootConfig)
}
func BuildWorksmobileUserPayloadForDomainTenant(user domain.User, tenant domain.Tenant, _ domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
return BuildWorksmobileUserPayloadForDomainTenants(user, tenant, map[string]domain.Tenant{tenant.ID: tenant}, rootConfig)
}
func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (WorksmobileUserPayload, error) {
if err := ValidateWorksmobileExternalKey(user.ID); err != nil {
return WorksmobileUserPayload{}, err
}
if tenant.ID == "" {
return WorksmobileUserPayload{}, errors.New("tenant is required")
}
if tenantByID == nil {
tenantByID = map[string]domain.Tenant{}
}
tenantByID[tenant.ID] = tenant
domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig)
if err != nil {
return WorksmobileUserPayload{}, err
}
if task == "" {
task = strings.TrimSpace(user.JobTitle)
}
payload := WorksmobileUserPayload{
DomainID: domainID,
Email: strings.TrimSpace(user.Email),
UserExternalKey: user.ID,
UserName: WorksmobileUserName{LastName: strings.TrimSpace(user.Name)},
CellPhone: strings.TrimSpace(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",
PasswordConfig: WorksmobilePasswordConfig{
PasswordCreationType: "ADMIN",
Password: GenerateWorksmobileInitialPassword(),
},
Task: task,
Organizations: organizations,
}
payload.AliasEmails = BuildWorksmobileAliasEmails(user, tenant)
return payload, nil
}
type worksmobileAppointment struct {
TenantID string
IsPrimary bool
IsManager bool
HasManager bool
JobTitle string
PositionID string
}
func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) ([]WorksmobileUserOrganization, string, error) {
appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
if len(appointments) == 0 {
appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}}
}
primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id")
if primaryTenantID == "" && user.TenantID != nil {
primaryTenantID = *user.TenantID
}
hasPrimary := false
for i := range appointments {
if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary {
appointments[i].IsPrimary = true
hasPrimary = true
break
}
}
if !hasPrimary {
for i := range appointments {
if appointments[i].TenantID == tenant.ID {
appointments[i].IsPrimary = true
break
}
}
}
organizations := make([]WorksmobileUserOrganization, 0, len(appointments))
seen := map[string]bool{}
task := ""
for _, appointment := range appointments {
if appointment.TenantID == "" || seen[appointment.TenantID] {
continue
}
appointmentTenant, ok := tenantByID[appointment.TenantID]
if !ok {
continue
}
if err := ValidateWorksmobileExternalKey(appointmentTenant.ID); err != nil {
return nil, "", err
}
domainTenant := worksmobileDomainClassificationTenant(appointmentTenant, tenantByID)
domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
if err != nil {
return nil, "", err
}
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
Primary: appointment.IsPrimary,
PositionID: appointment.PositionID,
}
if appointment.HasManager {
isManager := appointment.IsManager
orgUnit.IsManager = &isManager
}
organizations = append(organizations, WorksmobileUserOrganization{
DomainID: domainID,
Primary: appointment.IsPrimary,
OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
})
if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" {
task = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
}
if len(organizations) == 0 {
return nil, "", errors.New("no valid worksmobile organization")
}
sortWorksmobileOrganizations(organizations)
return organizations, task, nil
}
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
rawAppointments, ok := metadata["additionalAppointments"].([]any)
if !ok {
return nil
}
appointments := make([]worksmobileAppointment, 0, len(rawAppointments))
for _, raw := range rawAppointments {
item, ok := raw.(map[string]any)
if !ok {
continue
}
appointment := worksmobileAppointment{
TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"),
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
}
if isManager, ok := metadataOptionalBool(domain.JSONMap(item), "isManager", "lead", "isLead"); ok {
appointment.IsManager = isManager
appointment.HasManager = true
}
appointments = append(appointments, appointment)
}
return appointments
}
func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) {
sort.SliceStable(organizations, func(i, j int) bool {
if organizations[i].Primary != organizations[j].Primary {
return organizations[i].Primary
}
left := ""
right := ""
if len(organizations[i].OrgUnits) > 0 {
left = organizations[i].OrgUnits[0].OrgUnitID
}
if len(organizations[j].OrgUnits) > 0 {
right = organizations[j].OrgUnits[0].OrgUnitID
}
return left < right
})
}
func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []string {
candidates := make([]string, 0)
for _, key := range []string{
"aliasEmails",
"alias_emails",
"worksmobileAliasEmails",
"sub_email",
"secondary_email",
"secondary_emails",
"additional_email",
"additional_emails",
"naverworks_sub_email",
} {
candidates = append(candidates, metadataStringList(user.Metadata, key)...)
}
employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number")
if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" {
candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr")
}
return normalizeWorksmobileAliasEmails(user.Email, candidates)
}
func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) []string {
result := make([]string, 0, len(candidates))
seen := map[string]bool{}
primary := strings.ToLower(strings.TrimSpace(primaryEmail))
for _, candidate := range candidates {
normalized := strings.ToLower(strings.TrimSpace(candidate))
if normalized == "" || normalized == primary || seen[normalized] {
continue
}
if _, err := mail.ParseAddress(normalized); err != nil {
continue
}
seen[normalized] = true
result = append(result, normalized)
}
return result
}
func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error {
seen := map[string]string{}
primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail)
if err != nil {
return err
}
seen[primaryLocalPart] = primaryEmail
for _, aliasEmail := range aliasEmails {
localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail)
if err != nil {
return err
}
if previous, ok := seen[localPart]; ok {
return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail)
}
if owner, ok := existingLocalParts[localPart]; ok {
return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner)
}
seen[localPart] = aliasEmail
}
return nil
}
func GenerateWorksmobileInitialPassword() string {
digits := "0123456789"
letters := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
symbols := "!@#$%^&*()-_=+[]{}"
all := digits + letters + symbols
password := []byte{
randomChar(digits),
randomChar(letters),
randomChar(symbols),
}
for len(password) < 16 {
password = append(password, randomChar(all))
}
shuffleBytes(password)
return string(password)
}
func randomChar(chars string) byte {
if chars == "" {
return 'x'
}
index, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars))))
if err != nil {
return chars[0]
}
return chars[index.Int64()]
}
func shuffleBytes(values []byte) {
for i := len(values) - 1; i > 0; i-- {
j, err := rand.Int(rand.Reader, big.NewInt(int64(i+1)))
if err != nil {
continue
}
values[i], values[j.Int64()] = values[j.Int64()], values[i]
}
}
func WorksmobileUserStatusAction(status string) string {
normalized := domain.NormalizeUserStatus(status)
if domain.IsWorksDeprovisionUserStatus(normalized) {
return domain.WorksmobileActionDelete
}
switch normalized {
case domain.UserStatusSuspended:
return WorksmobileUserActionSuspend
default:
return WorksmobileUserActionUpsert
}
}
func ValidateWorksmobileExternalKey(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return errors.New("external key is required")
}
if strings.ContainsAny(value, `%\#/?`) {
return fmt.Errorf("external key contains unsupported character: %s", value)
}
return nil
}
func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap) (int64, error) {
envKey := worksmobileTenantDomainIDEnvKey(tenant)
if domainID, ok := worksmobileDomainIDFromEnv(envKey); ok {
return domainID, nil
}
return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey)
}
func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string {
if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") {
return "SAMAN_DOMAIN_ID"
}
if isHanmacWorksmobileTenant(tenant) {
return "HANMAC_DOMAIN_ID"
}
if tenantMatchesAny(tenant, "gpdtdc", "총괄", "기술개발센터", "기술개발") {
return "GPDTDC_DOMAIN_ID"
}
return "BARONGROUP_DOMAIN_ID"
}
func worksmobileDomainIDFromEnv(key string) (int64, bool) {
if key == "" {
return 0, false
}
id, ok := parseDomainID(os.Getenv(key))
return id, ok
}
type worksmobileDomainEnvMapping struct {
Key string
Label string
}
func worksmobileDomainEnvMappings() []worksmobileDomainEnvMapping {
return []worksmobileDomainEnvMapping{
{Key: "SAMAN_DOMAIN_ID", Label: "삼안"},
{Key: "HANMAC_DOMAIN_ID", Label: "한맥기술"},
{Key: "GPDTDC_DOMAIN_ID", Label: "총괄기획&기술개발센터"},
{Key: "BARONGROUP_DOMAIN_ID", Label: "바론그룹"},
}
}
func WorksmobileDomainIDsFromEnv() []int64 {
mappings := worksmobileDomainEnvMappings()
result := make([]int64, 0, len(mappings))
seen := map[int64]bool{}
for _, mapping := range mappings {
if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && !seen[id] {
seen[id] = true
result = append(result, id)
}
}
return result
}
func WorksmobileDomainLabelForID(domainID int64) string {
for _, mapping := range worksmobileDomainEnvMappings() {
if id, ok := worksmobileDomainIDFromEnv(mapping.Key); ok && id == domainID {
return mapping.Label
}
}
return ""
}
func isHanmacWorksmobileTenant(tenant domain.Tenant) bool {
return tenantHasDomain(tenant, "hanmaceng.co.kr") || tenantMatchesAny(tenant, "hanmac", "한맥")
}
func tenantHasDomain(tenant domain.Tenant, domainName string) bool {
domainName = strings.ToLower(strings.TrimSpace(domainName))
for _, d := range tenant.Domains {
if strings.EqualFold(strings.TrimSpace(d.Domain), domainName) {
return true
}
}
return false
}
func tenantMatchesAny(tenant domain.Tenant, needles ...string) bool {
haystack := strings.ToLower(strings.TrimSpace(tenant.Slug + " " + tenant.Name))
for _, needle := range needles {
if strings.Contains(haystack, strings.ToLower(strings.TrimSpace(needle))) {
return true
}
}
return false
}
func WorksmobileEnabled(rootConfig domain.JSONMap) bool {
rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any)
if !ok {
if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok {
rawWorksmobile = map[string]any(raw)
} else {
return false
}
}
enabled, _ := rawWorksmobile["enabled"].(bool)
return enabled
}
func WorksmobileDomainMappings(rootConfig domain.JSONMap) map[string]int64 {
result := map[string]int64{}
rawWorksmobile, ok := rootConfig["worksmobile"].(map[string]any)
if !ok {
if raw, ok := rootConfig["worksmobile"].(domain.JSONMap); ok {
rawWorksmobile = map[string]any(raw)
} else {
return result
}
}
rawMappings, ok := rawWorksmobile["domainMappings"].(map[string]any)
if !ok {
if raw, ok := rawWorksmobile["domainMappings"].(domain.JSONMap); ok {
rawMappings = map[string]any(raw)
} else {
return result
}
}
for key, raw := range rawMappings {
if id, ok := parseDomainID(raw); ok {
result[strings.ToLower(strings.TrimSpace(key))] = id
}
}
return result
}
func parseDomainID(raw any) (int64, bool) {
switch value := raw.(type) {
case int:
return int64(value), value > 0
case int64:
return value, value > 0
case float64:
id := int64(value)
return id, id > 0
case string:
id, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return id, err == nil && id > 0
default:
return 0, false
}
}
func metadataString(metadata domain.JSONMap, keys ...string) string {
for _, key := range keys {
if value, ok := metadata[key]; ok {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
}
return ""
}
func metadataBool(metadata domain.JSONMap, keys ...string) bool {
value, _ := metadataOptionalBool(metadata, keys...)
return value
}
func metadataOptionalBool(metadata domain.JSONMap, keys ...string) (bool, bool) {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch v := value.(type) {
case bool:
return v, true
case string:
normalized := strings.ToLower(strings.TrimSpace(v))
if normalized == "true" || normalized == "1" || normalized == "yes" {
return true, true
}
if normalized == "false" || normalized == "0" || normalized == "no" {
return false, true
}
case int:
return v != 0, true
case float64:
return v != 0, true
}
}
return false, false
}
func metadataStringList(metadata domain.JSONMap, keys ...string) []string {
for _, key := range keys {
value, ok := metadata[key]
if !ok {
continue
}
switch v := value.(type) {
case []string:
return splitWorksmobileAliasValues(v)
case []any:
values := make([]string, 0, len(v))
for _, item := range v {
values = append(values, strings.TrimSpace(fmt.Sprint(item)))
}
return splitWorksmobileAliasValues(values)
case string:
return splitWorksmobileAliasValues([]string{v})
default:
return splitWorksmobileAliasValues([]string{fmt.Sprint(v)})
}
}
return nil
}
func splitWorksmobileAliasValues(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t'
})
for _, field := range fields {
if trimmed := strings.TrimSpace(field); trimmed != "" {
result = append(result, trimmed)
}
}
}
return result
}