package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/pagination" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "context" "encoding/csv" "errors" "fmt" "log/slog" "net/http" "os" "regexp" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" ) // OryProviderAPI defines the subset of Ory Provider used by UserHandler type OryProviderAPI interface { CreateUser(user *domain.BrokerUser, password string) (string, error) UpdateUserPassword(loginID, newPassword string, r *http.Request) error GetPasswordPolicy() (*domain.PasswordPolicy, error) } type UserHandler struct { KratosAdmin service.KratosAdminService OryProvider OryProviderAPI TenantService service.TenantService KetoService service.KetoService KetoOutboxRepo repository.KetoOutboxRepository UserRepo repository.UserRepository UserProjectionRepo repository.UserProjectionRepository UserGroupRepo repository.UserGroupRepository AuditRepo domain.AuditRepository Worksmobile service.WorksmobileSyncer } func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, auditRepo domain.AuditRepository) *UserHandler { return &UserHandler{ KratosAdmin: kratosAdmin, OryProvider: oryProvider, TenantService: tenantService, KetoService: ketoService, KetoOutboxRepo: ketoOutboxRepo, UserRepo: userRepo, UserGroupRepo: userGroupRepo, AuditRepo: auditRepo, } } func (h *UserHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) { h.Worksmobile = syncer } func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[string]any, primaryTenantID string, primaryTenantName string, primaryTenantIsOwner *bool) map[string]any { if metadata == nil { metadata = map[string]any{} } if len(appointments) > 0 { values := make([]any, 0, len(appointments)) for _, appointment := range appointments { values = append(values, appointment) } metadata["additionalAppointments"] = values } if strings.TrimSpace(primaryTenantID) != "" { metadata["primaryTenantId"] = strings.TrimSpace(primaryTenantID) } if strings.TrimSpace(primaryTenantName) != "" { metadata["primaryTenantName"] = strings.TrimSpace(primaryTenantName) } if primaryTenantIsOwner != nil { metadata["primaryTenantIsOwner"] = *primaryTenantIsOwner } return metadata } func sanitizeUserMetadata(metadata map[string]any) map[string]any { if metadata == nil { return nil } sanitized := make(map[string]any, len(metadata)) for key, value := range metadata { if key == "hanmacFamily" || key == "userType" { continue } sanitized[key] = value } return sanitized } func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string { if value := strings.TrimSpace(primaryTenantID); value != "" { return value } if value := normalizeMetadataString(metadata["primaryTenantId"]); value != "" { return value } for _, appointment := range appointments { if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary { if value := normalizeMetadataString(appointment["tenantId"]); value != "" { return value } } } if len(appointments) > 0 { return normalizeMetadataString(appointments[0]["tenantId"]) } if raw, ok := metadata["additionalAppointments"].([]any); ok { for _, item := range raw { appointment, ok := item.(map[string]any) if !ok { continue } if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary"); ok && isPrimary { if value := normalizeMetadataString(appointment["tenantId"]); value != "" { return value } } } if len(raw) > 0 { if appointment, ok := raw[0].(map[string]any); ok { return normalizeMetadataString(appointment["tenantId"]) } } } return "" } func bulkUserEmailDomainCandidates(emailDomain string, email string) []string { values := make([]string, 0, 2) seen := map[string]bool{} add := func(value string) { normalized := strings.ToLower(strings.TrimSpace(value)) if normalized == "" || seen[normalized] { return } seen[normalized] = true values = append(values, normalized) } for _, value := range strings.FieldsFunc(emailDomain, func(r rune) bool { return r == ',' || r == ';' || r == '\n' || r == '\r' }) { add(value) } if _, domainPart, err := domain.SplitEmailDomain(email); err == nil { add(domainPart) } return values } func bulkUserAssignmentContainsTenant(appointments []any, primaryTenantID string, tenantID string) bool { if strings.TrimSpace(tenantID) == "" { return true } if primaryTenantID != "" && primaryTenantID == tenantID { return true } for _, item := range appointments { appointment, ok := item.(map[string]any) if !ok { continue } if normalizeMetadataString(appointment["tenantId"]) == tenantID { return true } } return false } func metadataBoolFromMap(metadata map[string]any, 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 float64: return v != 0, true case int: return v != 0, true } } return false, false } func roleFromTraits(traits map[string]interface{}) string { if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok { return role } if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "grade")); ok { return role } return domain.RoleUser } func gradeFromTraits(traits map[string]interface{}) string { value := strings.TrimSpace(extractTraitString(traits, "grade")) if value == "" { return "" } if _, ok := domain.NormalizeRoleAlias(value); ok { return "" } return value } func rejectLegacyCompanyCode(value string) error { if strings.TrimSpace(value) != "" { return errors.New("companyCode is deprecated; use tenantSlug") } return nil } func rejectLegacyCompanyCodePointer(value *string) error { if value == nil { return nil } return rejectLegacyCompanyCode(*value) } func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) (string, error) { if err := rejectLegacyCompanyCode(legacyCompanyCode); err != nil { return "", err } if value := strings.TrimSpace(tenantSlug); value != "" { return value, nil } return "", nil } func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) (*string, error) { if err := rejectLegacyCompanyCodePointer(legacyCompanyCode); err != nil { return nil, err } if tenantSlug != nil { value := strings.TrimSpace(*tenantSlug) return &value, nil } return nil, nil } func identityTenantAccessKeys(traits map[string]interface{}) []string { keys := make([]string, 0, 2) if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" { keys = append(keys, tenantID) } if legacySlug := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "companyCode"))); legacySlug != "" { keys = append(keys, legacySlug) } return keys } func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool { for _, key := range keys { if allowed[key] { return true } } return false } type userSummary struct { ID string `json:"id"` Email string `json:"email"` LoginID string `json:"loginId,omitempty"` CustomLoginIDs []string `json:"customLoginIds,omitempty"` // [New] 다중 로그인 ID 목록 Name string `json:"name"` Phone string `json:"phone"` Role string `json:"role"` Status string `json:"status"` TenantSlug string `json:"tenantSlug,omitempty"` CompanyCode string `json:"companyCode"` Metadata domain.JSONMap `json:"metadata,omitempty"` Tenant *domain.Tenant `json:"tenant,omitempty"` JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록 Department string `json:"department"` Grade string `json:"grade"` Position string `json:"position"` JobTitle string `json:"jobTitle"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` InitialPassword string `json:"initialPassword,omitempty"` } type userListResponse struct { Items []userSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` Total int64 `json:"total"` Cursor string `json:"cursor,omitempty"` NextCursor string `json:"nextCursor,omitempty"` } func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string) { timestamp := identity.CreatedAt if timestamp.IsZero() { timestamp = time.Unix(0, 0).UTC() } return timestamp, identity.ID } func (h *UserHandler) ListUsers(c *fiber.Ctx) error { // [New] Get requester profile from middleware var requesterRole string if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { requesterRole = domain.NormalizeRole(profile.Role) } limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) search := strings.TrimSpace(c.Query("search")) tenantSlug := strings.TrimSpace(c.Query("tenantSlug")) cursorRaw := strings.TrimSpace(c.Query("cursor")) if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } // [New] Manageable Tenants Map for efficient lookup manageableSlugs := make(map[string]bool) if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin { profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if profile != nil { var baseTenantIDs []string for _, t := range profile.ManageableTenants { manageableSlugs[strings.ToLower(t.Slug)] = true manageableSlugs[strings.ToLower(t.ID)] = true baseTenantIDs = append(baseTenantIDs, t.ID) } for _, t := range profile.JoinedTenants { manageableSlugs[strings.ToLower(t.Slug)] = true manageableSlugs[strings.ToLower(t.ID)] = true baseTenantIDs = append(baseTenantIDs, t.ID) } // Include primary tenant slug if not already there if profile.CompanyCode != "" { manageableSlugs[strings.ToLower(profile.CompanyCode)] = true } if profile.TenantID != nil { manageableSlugs[strings.ToLower(*profile.TenantID)] = true baseTenantIDs = append(baseTenantIDs, *profile.TenantID) } // Expand manageableSlugs to the entire tenant tree (root + all descendants) if h.TenantService != nil && len(baseTenantIDs) > 0 { allTenants, _, err := h.TenantService.ListTenants(c.Context(), 10000, 0, "") if err == nil { parentMap := make(map[string]string) for _, t := range allTenants { if t.ParentID != nil { parentMap[t.ID] = *t.ParentID } } // Function to find the root of any given tenant findRoot := func(id string) string { curr := id for { p, exists := parentMap[curr] if !exists || p == "" { break } curr = p } return curr } // Collect root IDs for all base tenants roots := make(map[string]bool) for _, id := range baseTenantIDs { roots[findRoot(id)] = true } // If a tenant shares a root with any base tenant, it's in the same tree family for _, t := range allTenants { if roots[findRoot(t.ID)] { manageableSlugs[strings.ToLower(t.Slug)] = true manageableSlugs[strings.ToLower(t.ID)] = true } } } } } } var targetTenantID string if tenantSlug != "" && h.TenantService != nil { t, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug) if err == nil && t != nil { targetTenantID = strings.ToLower(t.ID) } } if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } identities, err := h.KratosAdmin.ListIdentities(c.Context()) if err == nil { filtered := make([]service.KratosIdentity, 0, len(identities)) searchLower := strings.ToLower(search) for _, identity := range identities { email := strings.ToLower(extractTraitString(identity.Traits, "email")) name := strings.ToLower(extractTraitString(identity.Traits, "name")) compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) tID := strings.ToLower(extractTraitString(identity.Traits, "tenant_id")) secondaryCodes := extractTraitStringArray(identity.Traits, "companyCodes") // Tenant Admin & Member filtering if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin { hasAccess := manageableSlugs[compCode] || manageableSlugs[tID] if !hasAccess && len(secondaryCodes) > 0 { for _, code := range secondaryCodes { if manageableSlugs[strings.ToLower(code)] { hasAccess = true break } } } if !hasAccess { continue } } // Dedicated tenantSlug filter if tenantSlug != "" { matches := strings.EqualFold(compCode, tenantSlug) || tID == targetTenantID if !matches && len(secondaryCodes) > 0 { for _, code := range secondaryCodes { if strings.EqualFold(code, tenantSlug) { matches = true break } } } if !matches { continue } } // Search filtering (Keyword search in email, name, or companyCode) if search != "" { matchesSearch := strings.Contains(email, searchLower) || strings.Contains(name, searchLower) || strings.Contains(strings.ToLower(compCode), searchLower) if !matchesSearch && len(secondaryCodes) > 0 { for _, code := range secondaryCodes { if strings.Contains(strings.ToLower(code), searchLower) { matchesSearch = true break } } } if !matchesSearch { continue } } filtered = append(filtered, identity) } pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey) total := int64(len(filtered)) nextCursor := "" var pageIdentities []service.KratosIdentity if cursorRaw != "" { pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey) if err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid cursor") } offset = 0 } else { if offset > len(filtered) { offset = len(filtered) } end := offset + limit if end > len(filtered) { end = len(filtered) } pageIdentities = filtered[offset:end] if total > int64(end) && len(pageIdentities) > 0 { lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1]) nextCursor = pagination.Encode(lastTimestamp, lastID) } } items := make([]userSummary, 0, len(pageIdentities)) for _, identity := range pageIdentities { summary := h.mapIdentitySummary(c.Context(), identity) items = append(items, summary) } // [Lazy Sync] Asynchronously update local DB with fresh data from Kratos // This ensures that member counts (which use local DB) eventually match reality if h.UserRepo != nil { go func(ids []service.KratosIdentity) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() for _, identity := range ids { localUser := h.mapToLocalUser(identity) _ = h.UserRepo.Update(ctx, localUser) } }(filtered) } return c.JSON(userListResponse{ Items: items, Limit: limit, Offset: offset, Total: total, Cursor: cursorRaw, NextCursor: nextCursor, }) } slog.Warn("Kratos unavailable for user list", "error", err) return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider unavailable") } func (h *UserHandler) GetUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { return errorJSON(c, fiber.StatusBadRequest, "user id is required") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { return errorJSON(c, fiber.StatusNotFound, "user not found") } // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && requester.Role == domain.RoleTenantAdmin { compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode")) // Check if the target user's companyCode is in requester's manageable tenants allowed := false for _, t := range requester.ManageableTenants { if strings.ToLower(t.Slug) == compCode { allowed = true break } } // Also check primary company code if !allowed && strings.ToLower(requester.CompanyCode) == compCode { allowed = true } if !allowed { return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied") } } return c.JSON(h.mapIdentitySummary(c.Context(), *identity)) } func (h *UserHandler) CreateUser(c *fiber.Ctx) error { if h.OryProvider == nil || h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } var req struct { Email string `json:"email"` LoginID string `json:"loginId"` Password string `json:"password"` Name string `json:"name"` Phone string `json:"phone"` Role string `json:"role"` TenantSlug string `json:"tenantSlug"` CompanyCode string `json:"companyCode"` Department string `json:"department"` Grade string `json:"grade"` Position string `json:"position"` JobTitle string `json:"jobTitle"` PrimaryTenantID string `json:"primaryTenantId"` PrimaryTenantName string `json:"primaryTenantName"` PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"` AdditionalAppointments []map[string]any `json:"additionalAppointments"` Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } req.CompanyCode = tenantSlug req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)) email := strings.TrimSpace(req.Email) if email == "" { return errorJSON(c, fiber.StatusBadRequest, "email is required") } if !strings.Contains(email, "@") || !strings.Contains(email, ".") { return errorJSON(c, fiber.StatusBadRequest, "invalid email format") } name := strings.TrimSpace(req.Name) if name == "" { return errorJSON(c, fiber.StatusBadRequest, "name is required") } password := strings.TrimSpace(req.Password) policy, err := h.OryProvider.GetPasswordPolicy() if err != nil || policy == nil { policy = &domain.PasswordPolicy{ MinLength: 12, Lowercase: true, Uppercase: false, Number: true, NonAlphanumeric: true, MinCharacterTypes: 0, } } generatedPassword := "" if password == "" { generated, genErr := utils.GeneratePasswordWithPolicy(policy) if genErr != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to generate password") } password = generated generatedPassword = generated } else { if err := utils.ValidatePasswordWithPolicy(policy, password); err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } } role := domain.NormalizeRole(req.Role) if role == "" { role = domain.RoleUser } attributes := map[string]interface{}{ "department": req.Department, "grade": strings.TrimSpace(req.Grade), "position": req.Position, "jobTitle": req.JobTitle, "affiliationType": "internal", } // [Override with explicit LoginID if provided] if req.LoginID != "" { if ids, ok := attributes["custom_login_ids"].([]string); ok { attributes["custom_login_ids"] = append(ids, req.LoginID) } else { attributes["custom_login_ids"] = []string{req.LoginID} } } // [Resolve TenantID and Custom Login IDs before Kratos creation] var tenantID string requestedPrimaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments) if req.CompanyCode == "" && h.TenantService != nil { if requestedPrimaryTenantID != "" { if tenant, err := h.TenantService.GetTenant(c.Context(), requestedPrimaryTenantID); err == nil && tenant != nil { tenantID = tenant.ID req.CompanyCode = tenant.Slug } } } if req.CompanyCode != "" && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil { tenantID = tenant.ID } } if tenantID == "" { if req.CompanyCode != "" || requestedPrimaryTenantID != "" { return errorJSON(c, fiber.StatusBadRequest, "invalid tenant assignment") } tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email) if err != nil { return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant") } tenantID = tenant.ID req.CompanyCode = tenant.Slug } // Collect and sync all custom login IDs based on tenant schemas loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "") attributes["role"] = role if tenantID != "" { attributes["tenant_id"] = tenantID } if h.UserRepo != nil { if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil { if strings.Contains(err.Error(), "한맥가족") { return errorJSON(c, fiber.StatusConflict, err.Error()) } return errorJSON(c, fiber.StatusBadRequest, err.Error()) } } // Merge custom metadata into attributes for k, v := range req.Metadata { // Don't overwrite core fields if _, exists := attributes[k]; !exists { attributes[k] = v } } // Validate all collected LoginIDs if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok { for _, lid := range collectedIDs { if err := domain.ValidateLoginID(lid, email, normalizePhoneNumber(req.Phone)); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error()) } } } brokerUser := &domain.BrokerUser{ Email: email, Name: name, PhoneNumber: normalizePhoneNumber(req.Phone), Attributes: attributes, } // [Validation] Based on Tenant Schema if tenantID != "" && h.TenantService != nil { tenant, err := h.TenantService.GetTenant(c.Context(), tenantID) if err == nil && tenant != nil { if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if err := h.validateMetadata(req.Metadata, schema, true); err != nil { return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) } } } } identityID, err := h.OryProvider.CreateUser(brokerUser, password) if err != nil { if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") { return errorJSON(c, fiber.StatusConflict, "이미 사용 중인 식별자(이메일/전화번호/사번 등)입니다.") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Fetch the newly created identity to ensure we have all traits identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) } // [New] Local DB Sync - Ensure user exists in read-model if h.UserRepo != nil { localUser := h.mapToLocalUser(*identity) // Sync to local DB (Synchronous for immediate consistency) if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err) markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { slog.Warn("[UserHandler] Failed to enqueue Worksmobile user sync", "userID", localUser.ID, "error", err) } } // Update User Login IDs in local DB for i := range loginIDRecords { loginIDRecords[i].UserID = localUser.ID } if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil { slog.Error("[UserHandler] Failed to update user login IDs", "userID", localUser.ID, "error", err) markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } // [Keto] Sync relations via Outbox (Synchronous for accurate counting) if h.KetoOutboxRepo != nil { // 1. Role based relations h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID) // 2. Direct membership to the Tenant (for accurate counting) if localUser.TenantID != nil && *localUser.TenantID != "" { _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: *localUser.TenantID, Relation: "members", Subject: "User:" + localUser.ID, Action: domain.KetoOutboxActionCreate, }) } } } response := h.mapIdentitySummary(c.Context(), *identity) if generatedPassword != "" { response.InitialPassword = generatedPassword } return c.Status(fiber.StatusCreated).JSON(response) } type bulkUserItem struct { Email string `json:"email"` LoginID string `json:"loginId"` Name string `json:"name"` Phone string `json:"phone"` Role string `json:"role"` TenantID string `json:"tenantId"` TenantSlug string `json:"tenantSlug"` CompanyCode string `json:"companyCode"` EmailDomain string `json:"emailDomain"` Department string `json:"department"` Grade string `json:"grade"` Position string `json:"position"` JobTitle string `json:"jobTitle"` AdditionalAppointments []map[string]any `json:"additionalAppointments"` Metadata map[string]any `json:"metadata"` } type bulkUserResult struct { Email string `json:"email"` OriginalEmail string `json:"originalEmail,omitempty"` SuggestedEmail string `json:"suggestedEmail,omitempty"` Status string `json:"status,omitempty"` Warnings []string `json:"warnings,omitempty"` Success bool `json:"success"` Message string `json:"message,omitempty"` UserID string `json:"userId,omitempty"` } func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { if h.OryProvider == nil || h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } var req struct { Users []bulkUserItem `json:"users"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if len(req.Users) == 0 { return errorJSON(c, fiber.StatusBadRequest, "no users provided") } policy, err := h.OryProvider.GetPasswordPolicy() if err != nil || policy == nil { policy = &domain.PasswordPolicy{ MinLength: 12, Number: true, NonAlphanumeric: true, } } requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) results := make([]bulkUserResult, 0, len(req.Users)) var hanmacScope *hanmacEmailScope var hanmacLocalParts map[string]bool hanmacScopeLoaded := false // Pre-fetch tenant data to avoid redundant DB calls type tenantCacheItem struct { ID string Slug string Name string Schema []interface{} Groups []domain.UserGroup LoginIDField string } tenantCache := make(map[string]tenantCacheItem) tenantCacheByID := make(map[string]tenantCacheItem) tenantCacheByDomain := make(map[string]tenantCacheItem) buildTenantCacheItem := func(tenant *domain.Tenant) tenantCacheItem { tItem := tenantCacheItem{ ID: tenant.ID, Slug: tenant.Slug, Name: tenant.Name, } if s, ok := tenant.Config["userSchema"].([]interface{}); ok { tItem.Schema = s } if lf, ok := tenant.Config["loginIdField"].(string); ok { tItem.LoginIDField = lf } if h.UserGroupRepo != nil { if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil { tItem.Groups = groups } } return tItem } cacheTenantItem := func(tItem tenantCacheItem) tenantCacheItem { if tItem.Slug != "" { tenantCache[strings.ToLower(strings.TrimSpace(tItem.Slug))] = tItem } if tItem.ID != "" { tenantCacheByID[tItem.ID] = tItem } return tItem } resolveTenantBySlug := func(slug string) (tenantCacheItem, error) { normalizedSlug := strings.ToLower(strings.TrimSpace(slug)) if normalizedSlug == "" { return tenantCacheItem{}, errors.New("tenantSlug is required") } if tItem, exists := tenantCache[normalizedSlug]; exists { return tItem, nil } if h.TenantService == nil { return tenantCacheItem{}, errors.New("tenant service unavailable") } tenant, err := h.TenantService.GetTenantBySlug(c.Context(), normalizedSlug) if err != nil || tenant == nil { return tenantCacheItem{}, errors.New("invalid tenantSlug: tenant not found") } return cacheTenantItem(buildTenantCacheItem(tenant)), nil } resolveTenantByID := func(tenantID string) (tenantCacheItem, error) { normalizedID := strings.TrimSpace(tenantID) if normalizedID == "" { return tenantCacheItem{}, errors.New("tenantId is required") } if tItem, exists := tenantCacheByID[normalizedID]; exists { return tItem, nil } if h.TenantService == nil { return tenantCacheItem{}, errors.New("tenant service unavailable") } tenant, err := h.TenantService.GetTenant(c.Context(), normalizedID) if err != nil || tenant == nil { return tenantCacheItem{}, errors.New("invalid tenantId: tenant not found") } return cacheTenantItem(buildTenantCacheItem(tenant)), nil } resolveTenantByDomain := func(domainName string) (tenantCacheItem, bool) { normalizedDomain := strings.ToLower(strings.TrimSpace(domainName)) if normalizedDomain == "" || h.TenantService == nil { return tenantCacheItem{}, false } if tItem, exists := tenantCacheByDomain[normalizedDomain]; exists { return tItem, true } tenant, err := h.TenantService.GetTenantByDomain(c.Context(), normalizedDomain) if err != nil || tenant == nil { return tenantCacheItem{}, false } tItem := cacheTenantItem(buildTenantCacheItem(tenant)) tenantCacheByDomain[normalizedDomain] = tItem return tItem, true } createPersonalTenantItem := func(email string) (tenantCacheItem, error) { tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, email) if err != nil { return tenantCacheItem{}, err } return cacheTenantItem(buildTenantCacheItem(tenant)), nil } for _, item := range req.Users { email := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) tenantID := strings.TrimSpace(item.TenantID) tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode) dept := strings.TrimSpace(item.Department) if email == "" || name == "" { results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"}) continue } if tenantSlugErr != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()}) continue } var tItem tenantCacheItem var err error if tenantID != "" { tItem, err = resolveTenantByID(tenantID) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) continue } if tenantSlug != "" && !strings.EqualFold(tenantSlug, tItem.Slug) { results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantId and tenantSlug do not match"}) continue } tenantSlug = tItem.Slug } else if tenantSlug != "" { tItem, err = resolveTenantBySlug(tenantSlug) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) continue } tenantSlug = tItem.Slug } else { for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, email) { if domainTenant, ok := resolveTenantByDomain(domainName); ok { tItem = domainTenant tenantSlug = domainTenant.Slug break } } if tenantSlug == "" { tItem, err = createPersonalTenantItem(email) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to create personal tenant"}) continue } tenantSlug = tItem.Slug } } // Role-based access check if requester != nil && requester.Role == domain.RoleTenantAdmin { if tenantSlug != requester.CompanyCode { results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) continue } } resolvedAppointments := make([]any, 0, len(item.AdditionalAppointments)+2) if len(item.AdditionalAppointments) > 0 { appointmentFailed := false for _, rawAppointment := range item.AdditionalAppointments { appointmentTenantSlug := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantSlug"])) if appointmentTenantSlug == "" { continue } if requester != nil && requester.Role == domain.RoleTenantAdmin && appointmentTenantSlug != requester.CompanyCode { results = append(results, bulkUserResult{Email: email, Success: false, Message: "forbidden: cannot add users to another tenant"}) appointmentFailed = true break } appointmentTenant, exists := tenantCache[strings.ToLower(appointmentTenantSlug)] if !exists { appointmentTenant, err = resolveTenantBySlug(appointmentTenantSlug) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: strings.Replace(err.Error(), "tenantSlug", "additional tenantSlug", 1)}) appointmentFailed = true break } } appointment := make(map[string]any, len(rawAppointment)+3) for key, value := range rawAppointment { if key == "tenantSlug" || key == "tenantId" || key == "tenantName" { continue } appointment[key] = value } appointment["tenantId"] = appointmentTenant.ID appointment["tenantSlug"] = appointmentTenant.Slug if name := strings.TrimSpace(normalizeMetadataString(rawAppointment["tenantName"])); name != "" { appointment["tenantName"] = name } else { appointment["tenantName"] = appointmentTenant.Name } resolvedAppointments = append(resolvedAppointments, appointment) } if appointmentFailed { continue } } // Validation based on schema if tItem.Schema != nil { if err := h.validateMetadata(item.Metadata, tItem.Schema, true); err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: "validation failed: " + err.Error()}) continue } } if h.UserRepo != nil && !hanmacScopeLoaded { hanmacScopeLoaded = true var err error hanmacScope, err = h.resolveHanmacEmailScope(c.Context()) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to resolve Hanmac family tenant scope"}) continue } if hanmacScope != nil { hanmacLocalParts, err = h.loadHanmacLocalParts(c.Context(), hanmacScope) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: "failed to validate Hanmac family email policy"}) continue } } } userEmail := email var emailEvaluation hanmacEmailEvaluation if h.UserRepo != nil && hanmacScope != nil && hanmacScope.ContainsTenant(tItem.ID, tenantSlug) { emailEvaluation = h.evaluateHanmacImportEmail(c.Context(), item, hanmacScope, hanmacLocalParts) if emailEvaluation.Blocking { results = append(results, bulkUserResult{ Email: emailEvaluation.Email, OriginalEmail: emailEvaluation.OriginalEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: emailEvaluation.Message, }) continue } userEmail = emailEvaluation.Email if emailEvaluation.LocalPart != "" { hanmacLocalParts[emailEvaluation.LocalPart] = true } } else { if _, _, err := domain.SplitEmailDomain(email); err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"}) continue } if localPart, err := domain.ExtractNormalizedEmailLocalPart(email); err != nil || localPart == "" { results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: "invalid email format"}) continue } } for _, domainName := range bulkUserEmailDomainCandidates(item.EmailDomain, userEmail) { domainTenant, ok := resolveTenantByDomain(domainName) if !ok || bulkUserAssignmentContainsTenant(resolvedAppointments, tItem.ID, domainTenant.ID) { continue } resolvedAppointments = append(resolvedAppointments, map[string]any{ "tenantId": domainTenant.ID, "tenantSlug": domainTenant.Slug, "tenantName": domainTenant.Name, "assignmentSource": "email_domain", "sourceDomain": strings.ToLower(strings.TrimSpace(domainName)), }) } if len(resolvedAppointments) > 0 { if item.Metadata == nil { item.Metadata = map[string]any{} } item.Metadata["additionalAppointments"] = resolvedAppointments } item.Metadata = sanitizeUserMetadata(item.Metadata) password, _ := utils.GeneratePasswordWithPolicy(policy) role := item.Role if role == "" { role = "user" } attributes := map[string]interface{}{ "department": dept, "grade": strings.TrimSpace(item.Grade), "position": strings.TrimSpace(item.Position), "jobTitle": strings.TrimSpace(item.JobTitle), "affiliationType": "internal", "tenant_id": tItem.ID, "role": role, } // Sync all custom login IDs based on tenant schemas loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, item.Metadata, "") // Merge metadata for k, v := range item.Metadata { if _, exists := attributes[k]; !exists { attributes[k] = v } } userPhone := normalizePhoneNumber(item.Phone) // Validate all collected LoginIDs if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok { valid := true for _, lid := range collectedIDs { if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil { results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: "Invalid LoginID (" + lid + "): " + err.Error()}) valid = false break } } if !valid { continue } } identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ Email: userEmail, Name: item.Name, PhoneNumber: userPhone, Attributes: attributes, }, password) if err != nil { // 만약 이미 존재하는 사용자라면 로컬 DB 및 Keto 관계만 업데이트(Sync)를 시도 if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "exists already") { identityID, err = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail) if err != nil || identityID == "" { results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."}) continue } slog.Info("BulkCreate: User already exists, syncing local DB and Keto", "email", email, "identityID", identityID) } else { results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: false, Message: err.Error()}) continue } } // [CRITICAL FIX] Sync to local DB directly using current data // Don't fetch from Kratos here as it might have propagation lag if h.UserRepo != nil { localUser := &domain.User{ ID: identityID, Email: userEmail, Name: name, Phone: normalizePhoneNumber(item.Phone), Role: role, Status: "active", CompanyCode: tenantSlug, Department: dept, Grade: strings.TrimSpace(item.Grade), AffiliationType: "internal", CreatedAt: time.Now(), UpdatedAt: time.Now(), } if tItem.ID != "" { localUser.TenantID = &tItem.ID } // Merge metadata localUser.Metadata = make(domain.JSONMap) for k, v := range item.Metadata { localUser.Metadata[k] = v } if err := h.UserRepo.Update(c.Context(), localUser); err != nil { slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err) markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { slog.Warn("Failed to enqueue Worksmobile bulk user sync", "userID", localUser.ID, "error", err) } } // Update User Login IDs in local DB for i := range loginIDRecords { loginIDRecords[i].UserID = localUser.ID } if err := h.UserRepo.UpdateUserLoginIDs(c.Context(), localUser.ID, loginIDRecords); err != nil { slog.Error("Failed to update user login IDs in bulk", "userID", localUser.ID, "error", err) markUserProjectionFailed(c.Context(), h.UserProjectionRepo, err) } if h.KetoOutboxRepo != nil { // 1. Sync Role based relationship h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID) // 2. Sync direct membership to the Tenant (for count) if localUser.TenantID != nil && *localUser.TenantID != "" { _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: *localUser.TenantID, Relation: "members", Subject: "User:" + localUser.ID, Action: domain.KetoOutboxActionCreate, }) } // 3. Sync membership to UserGroup if department matches if dept != "" { for _, g := range tItem.Groups { if strings.EqualFold(strings.TrimSpace(g.Name), dept) { _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: g.ID, Relation: "members", Subject: "User:" + localUser.ID, Action: domain.KetoOutboxActionCreate, }) break } } } } } results = append(results, bulkUserResult{ Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: emailEvaluation.Status, Warnings: emailEvaluation.Warnings, Success: true, UserID: identityID, }) } return c.Status(fiber.StatusOK).JSON(fiber.Map{ "results": results, }) } func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error { search := strings.TrimSpace(c.Query("search")) tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode")) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } var requesterRole string var manageableSlugs []string var profile *domain.UserProfileResponse // [New] Manual profile resolution to support query-param role mocking // This is needed because browsers cannot send custom headers for direct downloads mockRole := c.Query("x-test-role") appEnv := strings.ToLower(os.Getenv("APP_ENV")) isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" if isDev && mockRole != "" { slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole) requesterRole = mockRole // In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin if requesterRole == domain.RoleTenantAdmin { // Try to get actual profile if possible to get manageableTenants p, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if p != nil { profile = p } } } else { // Use real profile from middleware p, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if !ok || p == nil { return errorJSON(c, fiber.StatusUnauthorized, "invalid session (trace:export_auth)") } profile = p requesterRole = profile.Role } // [New] Access Control: only admin roles can export if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export") } if profile != nil && requesterRole == domain.RoleTenantAdmin { for _, t := range profile.ManageableTenants { manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug)) } if profile.CompanyCode != "" { manageableSlugs = append(manageableSlugs, strings.ToLower(profile.CompanyCode)) } } // 1. Fetch Users using Repo for efficiency users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export") } // 2. Filter by manageable tenants if tenant_admin var filtered []domain.User if requesterRole == domain.RoleTenantAdmin { slugMap := make(map[string]bool) for _, s := range manageableSlugs { slugMap[s] = true } for _, u := range users { if slugMap[strings.ToLower(u.CompanyCode)] { filtered = append(filtered, u) } } } else { filtered = users } // 3. Set CSV Headers c.Set("Content-Type", "text/csv; charset=utf-8") c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv") // [New] Write UTF-8 BOM for Excel compatibility _, _ = c.Write([]byte{0xEF, 0xBB, 0xBF}) writer := csv.NewWriter(c) defer writer.Flush() // Header row includeIDs := includeCSVIds(c) header := []string{"Email", "Name", "Phone", "Status", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"} if includeIDs { header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "Grade", "Position", "JobTitle", "CreatedAt"} } // Collect all possible metadata keys for dynamic columns metaKeysMap := make(map[string]bool) for _, u := range filtered { for k := range u.Metadata { if !includeIDs && csvMetadataKeyIsID(k) { continue } metaKeysMap[k] = true } } var metaKeys []string for k := range metaKeysMap { metaKeys = append(metaKeys, k) header = append(header, "Meta:"+k) } if err := writer.Write(header); err != nil { return err } // Data rows for _, u := range filtered { tenantID := "" if u.TenantID != nil { tenantID = *u.TenantID } row := []string{ u.Email, u.Name, u.Phone, u.Status, u.CompanyCode, u.Grade, u.Position, u.JobTitle, u.CreatedAt.Format(time.RFC3339), } if includeIDs { row = []string{ u.ID, u.Email, u.Name, u.Phone, u.Status, tenantID, u.CompanyCode, u.Grade, u.Position, u.JobTitle, u.CreatedAt.Format(time.RFC3339), } } // Append metadata values in order for _, k := range metaKeys { val := "" if v, ok := u.Metadata[k]; ok { val = fmt.Sprintf("%v", v) } row = append(row, val) } if err := writer.Write(row); err != nil { return err } } return nil } func csvMetadataKeyIsID(key string) bool { normalized := strings.ToLower(strings.TrimSpace(key)) return normalized == "id" || normalized == "user_id" || normalized == "tenant_id" || normalized == "tenantid" } func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error { var req struct { UserIDs []string `json:"userIds"` Status *string `json:"status"` Role *string `json:"role"` TenantSlug *string `json:"tenantSlug"` CompanyCode *string `json:"companyCode"` Department *string `json:"department"` Grade *string `json:"grade"` Position *string `json:"position"` JobTitle *string `json:"jobTitle"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } req.CompanyCode = tenantSlug if len(req.UserIDs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided") } requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } if req.Role != nil { if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role") } role, ok := domain.NormalizeRoleAlias(*req.Role) if !ok { return errorJSON(c, fiber.StatusBadRequest, "invalid role") } *req.Role = role } // [New] Pre-fetch tenant cache if companyCode is being changed type tenantCacheItem struct { ID string Schema []interface{} } tenantCache := make(map[string]tenantCacheItem) manageableSlugs := make(map[string]bool) if requester.Role == domain.RoleTenantAdmin { for _, t := range requester.ManageableTenants { manageableSlugs[strings.ToLower(t.Slug)] = true } if requester.CompanyCode != "" { manageableSlugs[strings.ToLower(requester.CompanyCode)] = true } } results := make([]map[string]any, 0, len(req.UserIDs)) for _, id := range req.UserIDs { // [Safety] Cannot delete yourself if id == requester.ID { results = append(results, map[string]any{ "id": id, "success": false, "message": "cannot delete your own account for safety", }) continue } identity, err := h.KratosAdmin.GetIdentity(c.Context(), id) if err != nil { results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"}) continue } // Authorization check if requester.Role == domain.RoleTenantAdmin { if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) { results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"}) continue } // If changing companyCode, must be to a manageable one if req.CompanyCode != nil { if !manageableSlugs[strings.ToLower(*req.CompanyCode)] { results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: target tenant not manageable"}) continue } } } // Prepare updates traits := identity.Traits if req.Role != nil { traits["role"] = *req.Role } if req.CompanyCode != nil { delete(traits, "companyCode") delete(traits, "companyCodes") // Resolve and update tenant_id in traits if changed if tItem, exists := tenantCache[*req.CompanyCode]; exists { traits["tenant_id"] = tItem.ID } else if h.TenantService != nil { tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode) if err == nil && tenant != nil { tItem.ID = tenant.ID tenantCache[*req.CompanyCode] = tItem traits["tenant_id"] = tenant.ID } } } if req.Department != nil { traits["department"] = *req.Department } if req.Grade != nil { traits["grade"] = *req.Grade } if req.Position != nil { traits["position"] = *req.Position } if req.JobTitle != nil { traits["jobTitle"] = *req.JobTitle } state := identity.State if req.Status != nil { if *req.Status == "active" { state = "active" } else { state = "inactive" } } _, err = h.KratosAdmin.UpdateIdentity(c.Context(), id, traits, state) if err != nil { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } // Sync to local DB if h.UserRepo != nil { localUser := h.mapToLocalUser(*identity) oldRole := roleFromTraits(identity.Traits) oldTenantID := extractTraitString(identity.Traits, "tenant_id") if req.Role != nil { localUser.Role = *req.Role } if req.Status != nil { localUser.Status = *req.Status } if req.CompanyCode != nil { localUser.CompanyCode = *req.CompanyCode } if req.Department != nil { localUser.Department = *req.Department } if req.Position != nil { localUser.Position = *req.Position } if req.JobTitle != nil { localUser.JobTitle = *req.JobTitle } // Resolve TenantID if changing companyCode if req.CompanyCode != nil && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil { localUser.TenantID = &tenant.ID } } _ = h.UserRepo.Update(c.Context(), localUser) if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(c.Context(), *localUser); err != nil { slog.Warn("Failed to enqueue Worksmobile bulk user update sync", "userID", localUser.ID, "error", err) } } // [Keto Sync] if h.KetoOutboxRepo != nil { h.syncKetoRole(c.Context(), localUser.ID, localUser.Role, oldRole, oldTenantID, localUser.TenantID) } } results = append(results, map[string]any{"id": id, "success": true}) } return c.JSON(fiber.Map{"results": results}) } func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error { var req struct { UserIDs []string `json:"userIds"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if len(req.UserIDs) == 0 { return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided") } requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized") } manageableSlugs := make(map[string]bool) if requester.Role == domain.RoleTenantAdmin { for _, t := range requester.ManageableTenants { manageableSlugs[strings.ToLower(t.Slug)] = true } if requester.CompanyCode != "" { manageableSlugs[strings.ToLower(requester.CompanyCode)] = true } } results := make([]map[string]any, 0, len(req.UserIDs)) for _, id := range req.UserIDs { // [Safety] Cannot delete yourself if id == requester.ID { results = append(results, map[string]any{ "id": id, "success": false, "message": "cannot delete your own account for safety", }) continue } identity, err := h.KratosAdmin.GetIdentity(c.Context(), id) if err != nil { results = append(results, map[string]any{"id": id, "success": false, "message": "user not found"}) continue } // Authorization check if requester.Role == domain.RoleTenantAdmin { if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) { results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"}) continue } } err = h.KratosAdmin.DeleteIdentity(c.Context(), id) if err != nil { results = append(results, map[string]any{"id": id, "success": false, "message": err.Error()}) continue } if h.Worksmobile != nil { localUser := h.mapToLocalUser(*identity) if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { slog.Warn("Failed to enqueue Worksmobile bulk user delete", "userID", id, "error", err) } } // Local DB Sync if h.UserRepo != nil { _ = h.UserRepo.Delete(c.Context(), id) } results = append(results, map[string]any{"id": id, "success": true}) } return c.JSON(fiber.Map{"results": results}) } func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { return errorJSON(c, fiber.StatusBadRequest, "user id is required") } identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if identity == nil { return errorJSON(c, fiber.StatusNotFound, "user not found") } // Capture current local state for transition comparison var oldRole string var oldTenantID string var oldDepartment string if h.UserRepo != nil { if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil { oldRole = local.Role oldDepartment = local.Department if local.TenantID != nil { oldTenantID = *local.TenantID } } } // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { allowed := map[string]bool{} if requester.TenantID != nil { allowed[strings.ToLower(*requester.TenantID)] = true } if requester.CompanyCode != "" { allowed[strings.ToLower(requester.CompanyCode)] = true } for _, tenant := range requester.ManageableTenants { allowed[strings.ToLower(tenant.ID)] = true allowed[strings.ToLower(tenant.Slug)] = true } if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) { return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant") } } var req struct { LoginID *string `json:"loginId"` Password *string `json:"password"` Name *string `json:"name"` Phone *string `json:"phone"` Role *string `json:"role"` Status *string `json:"status"` TenantSlug *string `json:"tenantSlug"` CompanyCode *string `json:"companyCode"` IsAddTenant bool `json:"isAddTenant"` IsRemoveTenant bool `json:"isRemoveTenant"` Department *string `json:"department"` Grade *string `json:"grade"` Position *string `json:"position"` JobTitle *string `json:"jobTitle"` PrimaryTenantID string `json:"primaryTenantId"` PrimaryTenantName string `json:"primaryTenantName"` PrimaryTenantIsOwner *bool `json:"primaryTenantIsOwner"` AdditionalAppointments []map[string]any `json:"additionalAppointments"` Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } req.CompanyCode = tenantSlug req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)) if req.Role != nil { if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role") } role, ok := domain.NormalizeRoleAlias(*req.Role) if !ok { return errorJSON(c, fiber.StatusBadRequest, "invalid role") } *req.Role = role } // [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership) if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode { return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant") } } // [Validation] Based on Tenant Schema (Multi-tenant aware) isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin) // If metadata is namespaced (key is tenant ID), validate each namespace // If it's flat, validate using schemaCompCode for key, val := range req.Metadata { // Basic check if key looks like a UUID (tenant ID) if len(key) >= 32 { // Namespaced metadata if h.TenantService != nil { tenant, err := h.TenantService.GetTenant(c.Context(), key) if err == nil && tenant != nil { if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { if subMeta, ok := val.(map[string]any); ok { if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil { return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error()) } } } } } } else { // Legacy/Flat metadata - validate using primary tenant schema schemaCompCode := extractTraitString(identity.Traits, "companyCode") if req.CompanyCode != nil { schemaCompCode = *req.CompanyCode } if schemaCompCode != "" && h.TenantService != nil { tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode) if err == nil && tenant != nil { if schema, ok := tenant.Config["userSchema"].([]interface{}); ok { // For flat metadata, we validate the whole req.Metadata against this schema if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil { return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) } } } } break // Only need to check flat metadata once } } traits := identity.Traits if traits == nil { traits = map[string]interface{}{} } delete(traits, "hanmacFamily") delete(traits, "userType") if req.Name != nil { traits["name"] = strings.TrimSpace(*req.Name) } if req.Phone != nil { phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone)) if phone == "" { delete(traits, "phone_number") } else { traits["phone_number"] = phone } } if req.CompanyCode != nil { code := strings.TrimSpace(*req.CompanyCode) if req.IsRemoveTenant { if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" { go func(removedSlug string) { bgCtx := context.Background() if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil { _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: t.ID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } }(code) } if h.TenantService != nil && code != "" { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil { currentTenantID := extractTraitString(traits, "tenant_id") if currentTenantID == tenant.ID { traits["tenant_id"] = "" } } } } else if !req.IsAddTenant { if h.TenantService != nil && code != "" { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil { traits["tenant_id"] = tenant.ID } else { traits["tenant_id"] = "" } } } } delete(traits, "companyCode") delete(traits, "companyCodes") if req.Department != nil { traits["department"] = strings.TrimSpace(*req.Department) } if req.Grade != nil { traits["grade"] = strings.TrimSpace(*req.Grade) } if req.Position != nil { traits["position"] = strings.TrimSpace(*req.Position) } if req.JobTitle != nil { traits["jobTitle"] = strings.TrimSpace(*req.JobTitle) } if req.Role != nil { role := domain.NormalizeRole(*req.Role) if role == "" { role = domain.RoleUser } traits["role"] = role } // [Namespaced Metadata Sync] coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "role": true, "tenant_id": true, "custom_login_ids": true, "id": true, } // For namespaced metadata, we don't delete everything, we merge. for k, v := range req.Metadata { if !coreTraits[k] { // Ensure we are merging maps (tenant namespaces) correctly, not overwriting with slices if incomingMap, ok := v.(map[string]any); ok { if existingMap, ok := traits[k].(map[string]interface{}); ok { for subK, subV := range incomingMap { existingMap[subK] = subV } traits[k] = existingMap } else { traits[k] = incomingMap // New namespace } } else { traits[k] = v // Fallback for flat metadata } } } // [LoginID Sync based on Tenant Settings] // Perform sync AFTER metadata merge to ensure traits contains current values loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, traits, req.Metadata, userID) // Validate all collected LoginIDs userEmail := extractTraitString(traits, "email") userPhone := extractTraitString(traits, "phone_number") if collectedIDs, ok := traits["custom_login_ids"].([]string); ok { for _, lid := range collectedIDs { if err := domain.ValidateLoginID(lid, userEmail, userPhone); err != nil { return errorJSON(c, fiber.StatusBadRequest, "Invalid LoginID ("+lid+"): "+err.Error()) } } } state := normalizeKratosState(req.Status) slog.Info("[UpdateUser] Calling Kratos UpdateIdentity", "userID", userID, "traits", traits, "state", state) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) if err != nil { // [Exception Handling] Check for 409 Conflict (Duplicate Identifier) if strings.Contains(err.Error(), "409") || strings.Contains(err.Error(), "exists already") { return errorJSON(c, fiber.StatusConflict, "이미 다른 사용자가 사용 중인 식별자(이메일/전화번호/사번 등)가 포함되어 있습니다.") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller if h.UserRepo != nil { updatedLocalUser := h.mapToLocalUser(*updated) if req.Status != nil { updatedLocalUser.Status = normalizeStatus(*req.Status) } ctx := context.Background() // Use request context if appropriate, but sync must finish if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil { slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err) markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } if h.Worksmobile != nil { if err := h.Worksmobile.EnqueueUserUpsertIfInScope(ctx, *updatedLocalUser); err != nil { slog.Warn("[UserHandler] Failed to enqueue Worksmobile updated user sync", "userID", updatedLocalUser.ID, "error", err) } } // Update User Login IDs in local DB if err := h.UserRepo.UpdateUserLoginIDs(ctx, updatedLocalUser.ID, loginIDRecords); err != nil { slog.Error("[UserHandler] Failed to update user login IDs", "userID", updatedLocalUser.ID, "error", err) markUserProjectionFailed(ctx, h.UserProjectionRepo, err) } // [Keto Sync] asynchronously as it's less critical for immediate UI count go func() { bgCtx := context.Background() h.syncKetoRole(bgCtx, updatedLocalUser.ID, roleFromTraits(updated.Traits), oldRole, oldTenantID, updatedLocalUser.TenantID) // Try to automatically sync UserGroup membership based on Department if h.UserGroupRepo != nil && h.KetoOutboxRepo != nil { // 1. Remove from old group if department or tenant changed if oldTenantID != "" && oldDepartment != "" && (oldTenantID != extractTraitString(updated.Traits, "tenant_id") || oldDepartment != updatedLocalUser.Department) { if oldGroups, err := h.UserGroupRepo.ListByTenantID(bgCtx, oldTenantID); err == nil { for _, g := range oldGroups { if strings.EqualFold(g.Name, oldDepartment) { _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: g.ID, Relation: "members", Subject: "User:" + updatedLocalUser.ID, Action: domain.KetoOutboxActionDelete, }) break } } } } // 2. Add to new group if updatedLocalUser.TenantID != nil && updatedLocalUser.Department != "" { if groups, err := h.UserGroupRepo.ListByTenantID(bgCtx, *updatedLocalUser.TenantID); err == nil { for _, g := range groups { if strings.EqualFold(g.Name, updatedLocalUser.Department) { _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: g.ID, Relation: "members", Subject: "User:" + updatedLocalUser.ID, Action: domain.KetoOutboxActionCreate, }) break } } } } } // [Self-Healing] Sync all companyCodes to Keto if h.KetoOutboxRepo != nil && h.TenantService != nil { if codes, ok := updated.Traits["companyCodes"].([]interface{}); ok { for _, cVal := range codes { if cStr, ok := cVal.(string); ok && cStr != "" { if tenant, err := h.TenantService.GetTenantBySlug(bgCtx, cStr); err == nil && tenant != nil { _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "members", Subject: "User:" + updatedLocalUser.ID, Action: domain.KetoOutboxActionCreate, }) } } } } else if updatedLocalUser.TenantID != nil { // Fallback if companyCodes doesn't exist _ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{ Namespace: "Tenant", Object: *updatedLocalUser.TenantID, Relation: "members", Subject: "User:" + updatedLocalUser.ID, Action: domain.KetoOutboxActionCreate, }) } } }() } if req.Password != nil && *req.Password != "" { if h.OryProvider == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "password provider not available") } // [New] Resolve a representative LoginID for the password update call updateLoginID := resolvePasswordLoginID(updated.Traits) if err := h.OryProvider.UpdateUserPassword(updateLoginID, *req.Password, nil); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } return c.JSON(h.mapIdentitySummary(c.Context(), *updated)) } func (h *UserHandler) DeleteUser(c *fiber.Ctx) error { if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "identity provider not available") } userID := strings.TrimSpace(c.Params("id")) if userID == "" { return errorJSON(c, fiber.StatusBadRequest, "user id is required") } // [New] Check access scope before deletion requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) // [Safety] Cannot delete yourself if requester != nil && userID == requester.ID { return errorJSON(c, fiber.StatusForbidden, "cannot delete your own account for safety") } var identity *service.KratosIdentity if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin || h.Worksmobile != nil { found, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil { identity = found } } if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if identity != nil { allowed := map[string]bool{} if requester.TenantID != nil { allowed[strings.ToLower(*requester.TenantID)] = true } if requester.CompanyCode != "" { allowed[strings.ToLower(requester.CompanyCode)] = true } for _, tenant := range requester.ManageableTenants { allowed[strings.ToLower(tenant.ID)] = true allowed[strings.ToLower(tenant.Slug)] = true } if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) { return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant") } } } if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } if h.Worksmobile != nil && identity != nil { localUser := h.mapToLocalUser(*identity) if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil { slog.Warn("[UserHandler] Failed to enqueue Worksmobile user delete", "userID", userID, "error", err) } } // [Keto] Cleanup relations via Outbox if h.KetoOutboxRepo != nil { ctx := context.Background() _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "System", Object: "global", Relation: "super_admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) // Additional cleanup for tenants could be added here if we keep track of user's current tenants } if h.UserRepo != nil { if err := h.UserRepo.Delete(context.Background(), userID); err != nil { slog.Error("[UserHandler] Failed to delete local user read-model", "userID", userID, "error", err) markUserProjectionFailed(context.Background(), h.UserProjectionRepo, err) } } return c.SendStatus(fiber.StatusNoContent) } func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary { traits := identity.Traits role := roleFromTraits(traits) tenantID := extractTraitString(traits, "tenant_id") tenantSlug := "" var tenantSummary *domain.Tenant if tenantID != "" && h.TenantService != nil { if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil { tenantSlug = tenant.Slug tenantSummary = tenant } } slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug) var customLoginIDs []string if raw, ok := traits["custom_login_ids"]; ok { if ids, ok := raw.([]interface{}); ok { for _, id := range ids { if s, ok := id.(string); ok { customLoginIDs = append(customLoginIDs, s) } } } else if ids, ok := raw.([]string); ok { customLoginIDs = ids } } summary := userSummary{ ID: identity.ID, Email: extractTraitString(traits, "email"), LoginID: resolvePasswordLoginID(traits), CustomLoginIDs: customLoginIDs, Name: extractTraitString(traits, "name"), Phone: extractTraitString(traits, "phone_number"), Role: role, Status: normalizeStatus(identity.State), TenantSlug: tenantSlug, CompanyCode: tenantSlug, Department: extractTraitString(traits, "department"), Grade: gradeFromTraits(traits), Position: extractTraitString(traits, "position"), JobTitle: extractTraitString(traits, "jobTitle"), Metadata: make(domain.JSONMap), CreatedAt: formatTime(identity.CreatedAt), UpdatedAt: formatTime(identity.UpdatedAt), } // [New] Fetch all joined tenants (for Multi-tenancy support) if h.TenantService != nil { if joined, err := h.TenantService.ListJoinedTenants(ctx, identity.ID); err == nil { summary.JoinedTenants = joined } } // [Namespaced Metadata] Handling // We assume core traits are at the top level. // For other keys, if they are UUIDs (tenant IDs), we treat them as namespaced metadata. // Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "role": true, "tenant_id": true, "custom_login_ids": true, "id": true, } for k, v := range traits { if coreTraits[k] { continue } // If the key is a tenant ID (uuid-like), it's namespaced metadata // If not, it's flat metadata (for backward compatibility) summary.Metadata[k] = v } summary.Tenant = tenantSummary return summary } func (h *UserHandler) normalizePhoneNumber(phone string) string { return normalizePhoneNumber(phone) } func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User { traits := identity.Traits role := roleFromTraits(traits) user := &domain.User{ ID: identity.ID, Email: extractTraitString(traits, "email"), Name: extractTraitString(traits, "name"), Phone: extractTraitString(traits, "phone_number"), Role: role, Status: normalizeStatus(identity.State), Department: extractTraitString(traits, "department"), Grade: gradeFromTraits(traits), Position: extractTraitString(traits, "position"), JobTitle: extractTraitString(traits, "jobTitle"), AffiliationType: extractTraitString(traits, "affiliationType"), CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt, } // 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable) tID := extractTraitString(traits, "tenant_id") if tID != "" { user.TenantID = &tID } // Metadata handling (exclude core fields) user.Metadata = make(domain.JSONMap) coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "companyCodes": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "role": true, "tenant_id": true, "company_code": true, "custom_login_ids": true, "id": true, } for k, v := range traits { if !coreTraits[k] { user.Metadata[k] = v } } return user } func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) { newRole = domain.NormalizeRole(newRole) oldRole = domain.NormalizeRole(oldRole) newTID := "" if newTenantID != nil { newTID = *newTenantID } if h.KetoOutboxRepo == nil { return } if oldRole == newRole && oldTenantID == newTID { return // Nothing changed } // 1. Handle Role Changes if oldRole == domain.RoleSuperAdmin { // Only remove super_admin if the role actually changed (tenant change doesn't matter for global roles) if oldRole != newRole { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "System", Object: "global", Relation: "super_admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } } else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: oldTenantID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } // Add new roles if newRole == domain.RoleSuperAdmin { if oldRole != newRole { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "System", Object: "global", Relation: "super_admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } } else if newRole == domain.RoleTenantAdmin && newTID != "" { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: newTID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } // 2. Handle Tenant Membership (for count) if oldTenantID != newTID { // Remove from old tenant if oldTenantID != "" { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: oldTenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } // Add to new tenant if newTID != "" { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: newTID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } } } func extractTraitString(traits map[string]interface{}, key string) string { if traits == nil { return "" } if raw, ok := traits[key]; ok { if value, ok := raw.(string); ok { return value } } return "" } func extractTraitStringArray(traits map[string]interface{}, key string) []string { if traits == nil { return nil } if raw, ok := traits[key]; ok { if slice, ok := raw.([]interface{}); ok { var result []string for _, v := range slice { if s, ok := v.(string); ok { result = append(result, s) } } return result } if slice, ok := raw.([]string); ok { return slice } } return nil } func resolvePasswordLoginID(traits map[string]interface{}) string { // First check custom_login_ids (array) if raw, ok := traits["custom_login_ids"]; ok { if ids, ok := raw.([]interface{}); ok && len(ids) > 0 { if first, ok := ids[0].(string); ok { return first } } else if ids, ok := raw.([]string); ok && len(ids) > 0 { return ids[0] } } // Fallback to legacy id (if still exists in some old identities) if loginID := strings.TrimSpace(extractTraitString(traits, "id")); loginID != "" { return loginID } if email := strings.TrimSpace(extractTraitString(traits, "email")); email != "" { return email } return strings.TrimSpace(extractTraitString(traits, "phone_number")) } // syncCustomLoginIDs collects all fields marked as isLoginId: true from tenant schemas // and populates traits["custom_login_ids"] and returns domain.UserLoginID records for DB. func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]interface{}, metadata map[string]any, userID string) []domain.UserLoginID { if tenantService == nil { return nil } var loginIDRecords []domain.UserLoginID var allCustomIDs []string idSet := make(map[string]bool) // Collect tenant IDs to check schemas for tenantIDsToCheck := make(map[string]bool) for k, v := range metadata { // Heuristic: if it's a map, it's likely namespaced metadata for a tenant if _, ok := v.(map[string]any); ok { tenantIDsToCheck[k] = true } else if _, ok := v.(map[string]interface{}); ok { tenantIDsToCheck[k] = true } } // Also check primary tenant if available if tid := extractTraitString(traits, "tenant_id"); tid != "" { tenantIDsToCheck[tid] = true } for tid := range tenantIDsToCheck { tenant, err := tenantService.GetTenant(ctx, tid) if err != nil || tenant == nil { continue } schema, ok := tenant.Config["userSchema"].([]interface{}) if !ok { continue } for _, fieldRaw := range schema { field, ok := fieldRaw.(map[string]interface{}) if !ok { continue } isLoginId, _ := field["isLoginId"].(bool) if !isLoginId { continue } fieldKey, ok := field["key"].(string) if !ok { continue } // Try to find value in namespaced metadata first, then flat metadata, then existing traits var val string if namespaced, ok := metadata[tid].(map[string]any); ok { val, _ = namespaced[fieldKey].(string) } else if namespaced, ok := metadata[tid].(map[string]interface{}); ok { val, _ = namespaced[fieldKey].(string) } if val == "" { val, _ = metadata[fieldKey].(string) } if val == "" { // Check existing trait (namespaced) if namespaced, ok := traits[tid].(map[string]interface{}); ok { val, _ = namespaced[fieldKey].(string) } else if namespaced, ok := traits[tid].(map[string]any); ok { val, _ = namespaced[fieldKey].(string) } } if val == "" { // Fallback: Check flat traits val = extractTraitString(traits, fieldKey) } if val != "" { if !idSet[val] { idSet[val] = true allCustomIDs = append(allCustomIDs, val) } loginIDRecords = append(loginIDRecords, domain.UserLoginID{ UserID: userID, TenantID: tid, FieldKey: fieldKey, LoginID: val, }) } } } if len(allCustomIDs) > 0 { traits["custom_login_ids"] = allCustomIDs } else { delete(traits, "custom_login_ids") } // Always remove legacy "id" trait to avoid confusion delete(traits, "id") return loginIDRecords } func formatTime(value time.Time) string { if value.IsZero() { return "" } return value.Format(time.RFC3339) } func normalizeStatus(state string) string { state = strings.ToLower(strings.TrimSpace(state)) if state == "blocked" { return domain.UserStatusInactive } if state == domain.UserStatusInactive || state == domain.UserStatusSuspended || state == domain.UserStatusLeaveOfAbsence || state == domain.UserStatusActive { return state } if state == "" { return domain.UserStatusActive } return state } func normalizeKratosState(status *string) string { if status == nil { return "" } value := strings.ToLower(strings.TrimSpace(*status)) if value == "blocked" { return domain.UserStatusInactive } if value == domain.UserStatusActive { return domain.UserStatusActive } if value == domain.UserStatusInactive || value == domain.UserStatusSuspended || value == domain.UserStatusLeaveOfAbsence { return domain.UserStatusInactive } return "" } func normalizePhoneNumber(phone string) string { normalized := strings.ReplaceAll(phone, "-", "") normalized = strings.ReplaceAll(normalized, " ", "") if normalized == "" { return "" } if strings.HasPrefix(normalized, "010") { return "+82" + normalized[1:] } if strings.HasPrefix(normalized, "82") { return "+" + normalized } return normalized } func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error { return h.validateMetadataWithAuth(metadata, schema, true, checkRequired) } func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []interface{}, isAdmin bool, checkRequired bool) error { schemaMap := make(map[string]map[string]interface{}) for _, s := range schema { if m, ok := s.(map[string]interface{}); ok { if key, ok := m["key"].(string); ok { schemaMap[key] = m } } } // 1. Check required fields if checkRequired { for key, config := range schemaMap { required, _ := config["required"].(bool) val, exists := metadata[key] if required && (!exists || val == nil || val == "") { return errors.New("field " + key + " is required") } } } // 2. Check each field in metadata for key, val := range metadata { config, exists := schemaMap[key] if !exists { continue // Ignore fields not in schema or allow? Let's allow for now } // Admin Only check adminOnly, _ := config["adminOnly"].(bool) if adminOnly && !isAdmin { return errors.New("field " + key + " is admin only") } // Type validation if expectedType, ok := config["type"].(string); ok && expectedType != "" && val != nil && val != "" { switch expectedType { case "number": var numVal float64 switch v := val.(type) { case float64: numVal = v case int: numVal = float64(v) case string: parsed, err := strconv.ParseFloat(v, 64) if err != nil { return errors.New("field " + key + " must be a number") } numVal = parsed default: return errors.New("field " + key + " must be a number") } if float64(int(numVal)) != numVal { return errors.New("field " + key + " must be an integer") } if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 { return errors.New("field " + key + " must be an unsigned integer") } case "float": var numVal float64 switch v := val.(type) { case float64: numVal = v case int: numVal = float64(v) case string: parsed, err := strconv.ParseFloat(v, 64) if err != nil { return errors.New("field " + key + " must be a float") } numVal = parsed default: return errors.New("field " + key + " must be a float") } if unsigned, ok := config["unsigned"].(bool); ok && unsigned && numVal < 0 { return errors.New("field " + key + " must be an unsigned float") } case "boolean": switch v := val.(type) { case bool: // ok case string: if v != "true" && v != "false" { return errors.New("field " + key + " must be a boolean") } default: return errors.New("field " + key + " must be a boolean") } case "date": if strVal, ok := val.(string); ok { if _, err := time.Parse("2006-01-02", strVal); err != nil { return errors.New("field " + key + " must be a valid date (YYYY-MM-DD)") } } else { return errors.New("field " + key + " must be a date string") } case "datetime": if strVal, ok := val.(string); ok { _, err1 := time.Parse(time.RFC3339, strVal) _, err2 := time.Parse("2006-01-02T15:04", strVal) _, err3 := time.Parse("2006-01-02T15:04:05", strVal) if err1 != nil && err2 != nil && err3 != nil { return errors.New("field " + key + " must be a valid datetime") } } else { return errors.New("field " + key + " must be a datetime string") } case "text": if _, ok := val.(string); !ok { return errors.New("field " + key + " must be a string") } } } // Regex validation if regexStr, ok := config["validation"].(string); ok && regexStr != "" { strVal := "" switch v := val.(type) { case string: strVal = v case float64: strVal = fmt.Sprintf("%v", v) case int: strVal = fmt.Sprintf("%v", v) } if strVal != "" { matched, err := regexp.MatchString(regexStr, strVal) if err != nil { return errors.New("invalid regex pattern for field " + key) } if !matched { return errors.New("field " + key + " does not match validation pattern") } } } } return nil } func (h *UserHandler) GetUserRpHistory(c *fiber.Ctx) error { userId := c.Params("id") if userId == "" { return errorJSON(c, fiber.StatusBadRequest, "user id is required") } if h.AuditRepo == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable") } logs, err := h.AuditRepo.FindByUserAndEvents(c.Context(), userId, []string{"consent.granted", "consent.revoked"}, 100) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch history") } type rpHistoryItem struct { ClientID string `json:"client_id"` ClientName string `json:"client_name"` LastLoginAt string `json:"lastLoginAt"` Status string `json:"status"` } historyMap := make(map[string]*rpHistoryItem) // Logs are DESC (newest first). for _, log := range logs { details, _ := utils.ParseAuditDetails(log.Details) cid, _ := details["client_id"].(string) if cid == "" { continue } if _, exists := historyMap[cid]; !exists { cname, _ := details["client_name"].(string) if cname == "" { cname = cid } historyMap[cid] = &rpHistoryItem{ ClientID: cid, ClientName: cname, LastLoginAt: log.Timestamp.Format(time.RFC3339), Status: "active", // Default based on latest grant } if log.EventType == "consent.revoked" { historyMap[cid].Status = "revoked" } } } result := make([]*rpHistoryItem, 0, len(historyMap)) for _, item := range historyMap { result = append(result, item) } return c.JSON(result) }