1
0
forked from baron/baron-sso

feat(user): support fixed UUID registration and enhance bulk import results

- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
This commit is contained in:
2026-06-01 15:34:08 +09:00
parent 4a1e89e421
commit 31d107ff2e
85 changed files with 2104 additions and 1149 deletions

View File

@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"net/http"
"os"
"regexp"
@@ -201,7 +202,7 @@ func metadataBoolFromMap(metadata map[string]any, keys ...string) (bool, bool) {
return false, false
}
func roleFromTraits(traits map[string]interface{}) string {
func roleFromTraits(traits map[string]any) string {
if role, ok := domain.NormalizeRoleAlias(extractTraitString(traits, "role")); ok {
return role
}
@@ -219,7 +220,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
return role, role == domain.RoleSuperAdmin || role == domain.RoleUser
}
func gradeFromTraits(traits map[string]interface{}) string {
func gradeFromTraits(traits map[string]any) string {
value := strings.TrimSpace(extractTraitString(traits, "grade"))
if value == "" {
return ""
@@ -265,7 +266,7 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
return nil, nil
}
func identityTenantAccessKeys(traits map[string]interface{}) []string {
func identityTenantAccessKeys(traits map[string]any) []string {
keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID)
@@ -523,10 +524,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
end := min(offset+limit, len(filtered))
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
@@ -578,11 +576,32 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
}
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")
if err != nil || identity == nil {
// [FIX] Support fixed UUID lookup fallback
id, searchErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
if searchErr == nil && id != "" {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
if err != nil || identity == nil {
// Second Fallback: By Email from local DB
if h.UserRepo != nil {
local, _ := h.UserRepo.FindByID(c.Context(), userID)
if local != nil && local.Email != "" {
id, _ = h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
if id != "" {
identity, err = h.KratosAdmin.GetIdentity(c.Context(), id)
}
}
}
}
if err != nil || identity == nil {
if identity == nil {
return errorJSON(c, fiber.StatusNotFound, "user not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
// [New] Check access scope
@@ -685,7 +704,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
role = normalizedRole
}
attributes := map[string]interface{}{
attributes := map[string]any{
"department": req.Department,
"grade": strings.TrimSpace(req.Grade),
"position": req.Position,
@@ -775,7 +794,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
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 schema, ok := tenant.Config["userSchema"].([]any); ok {
if err := h.validateMetadata(req.Metadata, schema, true); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
}
@@ -850,6 +869,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
type bulkUserItem struct {
ID string `json:"id"`
UUID string `json:"uuid"`
Email string `json:"email"`
LoginID string `json:"loginId"`
Name string `json:"name"`
@@ -876,6 +897,7 @@ type bulkUserResult struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
UserID string `json:"userId,omitempty"`
ModifiedFields []string `json:"modifiedFields,omitempty"`
}
func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
@@ -912,7 +934,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
ID string
Slug string
Name string
Schema []interface{}
Schema []any
Groups []domain.UserGroup
LoginIDField string
}
@@ -926,7 +948,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Slug: tenant.Slug,
Name: tenant.Name,
}
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
if s, ok := tenant.Config["userSchema"].([]any); ok {
tItem.Schema = s
}
if lf, ok := tenant.Config["loginIdField"].(string); ok {
@@ -1012,6 +1034,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
for _, item := range req.Users {
var identityID string
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
@@ -1200,7 +1223,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
role = "user"
}
attributes := map[string]interface{}{
attributes := map[string]any{
"department": dept,
"grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
@@ -1222,13 +1245,27 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
userPhone := normalizePhoneNumber(item.Phone)
// Validate provided UUID if any
requestedID := strings.TrimSpace(item.ID)
if requestedID == "" {
requestedID = strings.TrimSpace(item.UUID)
}
if requestedID != "" {
// Basic UUID format validation
matched, _ := regexp.MatchString(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, strings.ToLower(requestedID))
if !matched {
results = append(results, bulkUserResult{Email: userEmail, Success: false, Message: "invalid UUID format: " + requestedID})
continue
}
}
// Validate all collected LoginIDs
if collectedIDs, ok := attributes["custom_login_ids"].([]string); ok {
valid := true
// Collect all emails
allEmails := []string{userEmail}
if secondaryRaw, exists := item.Metadata["sub_email"]; exists {
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
if secondaryEmails, ok := secondaryRaw.([]any); ok {
for _, se := range secondaryEmails {
if seStr, ok := se.(string); ok {
allEmails = append(allEmails, seStr)
@@ -1251,32 +1288,125 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
}
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: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
resultStatus := "created"
// 1. Search-first for Idempotency and Fixed UUID Guarantee
if requestedID != "" {
// Use h.KratosAdmin to search for existing ID or ExternalID
existingID, _ := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), requestedID)
if existingID != "" {
// Verify it's the same user (optional, but safer)
identityID = existingID
resultStatus = "updated"
slog.Info("BulkCreate: Found existing identity by UUID/Identifier", "requestedID", requestedID, "identityID", identityID)
}
}
if identityID == "" {
var err error
identityID, err = h.OryProvider.CreateUser(&domain.BrokerUser{
ID: requestedID,
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") || strings.Contains(err.Error(), "external_id") {
// Detect if it's a UUID conflict or Identifier (Email/LoginID) conflict
if requestedID != "" && (strings.Contains(err.Error(), requestedID) || strings.Contains(err.Error(), "uuid already exists") || strings.Contains(err.Error(), "external_id")) {
// Check if the EXISTING user with this UUID is actually the same person (same email)
existingID, lookupErr := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userEmail)
if lookupErr == nil && existingID != "" {
identityID = existingID
resultStatus = "updated"
slog.Info("BulkCreate: Conflict detected but same email, reusing identity", "email", userEmail, "identityID", identityID)
} else {
results = append(results, bulkUserResult{Email: userEmail, OriginalEmail: emailEvaluation.OriginalEmail, SuggestedEmail: emailEvaluation.SuggestedEmail, Status: "blockingError", Warnings: emailEvaluation.Warnings, Success: false, Message: "Conflict: UUID already exists (" + requestedID + ")"})
continue
}
} else {
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
}
resultStatus = "updated"
slog.Info("BulkCreate: User already exists by identifier, reusing identity", "email", userEmail, "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
}
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
resultStatus = "created"
slog.Info("BulkCreate: New identity created", "email", userEmail, "identityID", identityID)
}
} else {
slog.Info("BulkCreate: Existing identity found by search-first", "email", userEmail, "identityID", identityID)
}
var modifiedFields []string
isRestoration := false
if resultStatus == "updated" && identityID != "" {
existing, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err == nil && existing != nil {
// 1. Check for Restoration (Soft-deleted in local DB)
if h.UserRepo != nil {
var existingLocal domain.User
err := h.UserRepo.DB().Unscoped().Where("id = ?", identityID).First(&existingLocal).Error
if err == nil {
// Check if it was soft-deleted
if existingLocal.DeletedAt.Valid {
isRestoration = true
modifiedFields = append(modifiedFields, "Status")
}
}
}
// 2. Compare Traits
if name != "" && !strings.EqualFold(extractTraitString(existing.Traits, "name"), name) {
modifiedFields = append(modifiedFields, "Name")
}
if item.Phone != "" && normalizePhoneNumber(extractTraitString(existing.Traits, "phone_number")) != normalizePhoneNumber(item.Phone) {
modifiedFields = append(modifiedFields, "Phone")
}
if dept != "" && !strings.EqualFold(extractTraitString(existing.Traits, "department"), dept) {
modifiedFields = append(modifiedFields, "Department")
}
if item.Grade != "" && !strings.EqualFold(gradeFromTraits(existing.Traits), strings.TrimSpace(item.Grade)) {
modifiedFields = append(modifiedFields, "Grade")
}
if item.Position != "" && !strings.EqualFold(extractTraitString(existing.Traits, "position"), strings.TrimSpace(item.Position)) {
modifiedFields = append(modifiedFields, "Position")
}
if item.JobTitle != "" && !strings.EqualFold(extractTraitString(existing.Traits, "jobTitle"), strings.TrimSpace(item.JobTitle)) {
modifiedFields = append(modifiedFields, "JobTitle")
}
if tItem.ID != "" && extractTraitString(existing.Traits, "tenant_id") != tItem.ID {
modifiedFields = append(modifiedFields, "Tenant")
}
if role != "" && !strings.EqualFold(roleFromTraits(existing.Traits), role) {
modifiedFields = append(modifiedFields, "Role")
}
// 3. Finalize Status: If no fields actually changed, it's "unchanged"
if len(modifiedFields) == 0 && !isRestoration {
resultStatus = "unchanged"
}
}
}
// [CRITICAL FIX] Sync to local DB directly using current data
// Don't fetch from Kratos here as it might have propagation lag
// Use the REQUESTED UUID as the primary ID for Baron SSO if provided
targetLocalID := identityID
if requestedID != "" {
targetLocalID = requestedID
}
if h.UserRepo != nil {
localUser := &domain.User{
ID: identityID,
ID: targetLocalID,
Email: userEmail,
Name: name,
Phone: normalizePhoneNumber(item.Phone),
@@ -1295,9 +1425,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
// Merge metadata
localUser.Metadata = make(domain.JSONMap)
for k, v := range item.Metadata {
localUser.Metadata[k] = v
}
maps.Copy(localUser.Metadata, item.Metadata)
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
@@ -1313,9 +1441,30 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
for i := range loginIDRecords {
loginIDRecords[i].UserID = localUser.ID
}
// [FIX] Pre-check and cleanup colliding identifiers from OTHER users
for _, lid := range loginIDRecords {
var existingID domain.UserLoginID
// Search in DB (including soft-deleted) for any record with the same login_id but DIFFERENT user_id
if err := h.UserRepo.DB().Unscoped().Where("login_id = ? AND user_id != ?", lid.LoginID, localUser.ID).First(&existingID).Error; err == nil {
slog.Info("BulkCreate: Cleaning up colliding identifier from another user", "loginID", lid.LoginID, "oldUserID", existingID.UserID)
_ = h.UserRepo.DB().Unscoped().Where("login_id = ?", lid.LoginID).Delete(&domain.UserLoginID{}).Error
}
}
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)
results = append(results, bulkUserResult{
Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail,
SuggestedEmail: emailEvaluation.SuggestedEmail,
Status: "blockingError",
Warnings: emailEvaluation.Warnings,
Success: false,
Message: "LoginID sync failed: " + err.Error(),
})
continue
}
if h.KetoOutboxRepo != nil {
@@ -1355,10 +1504,11 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
Email: userEmail,
OriginalEmail: emailEvaluation.OriginalEmail,
SuggestedEmail: emailEvaluation.SuggestedEmail,
Status: emailEvaluation.Status,
Status: resultStatus,
Warnings: emailEvaluation.Warnings,
Success: true,
UserID: identityID,
UserID: targetLocalID,
ModifiedFields: modifiedFields,
})
}
@@ -1575,7 +1725,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
// Pre-fetch tenant cache if tenantSlug is being changed.
type tenantCacheItem struct {
ID string
Schema []interface{}
Schema []any
}
tenantCache := make(map[string]tenantCacheItem)
@@ -1913,7 +2063,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
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 schema, ok := tenant.Config["userSchema"].([]any); 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())
@@ -1934,7 +2084,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
tenant, _ = h.TenantService.GetTenant(c.Context(), tenantID)
}
if tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
if schema, ok := tenant.Config["userSchema"].([]any); ok {
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
}
@@ -1946,7 +2096,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits := identity.Traits
if traits == nil {
traits = map[string]interface{}{}
traits = map[string]any{}
}
delete(traits, "hanmacFamily")
delete(traits, "userType")
@@ -2035,10 +2185,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
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
}
if existingMap, ok := traits[k].(map[string]any); ok {
maps.Copy(existingMap, incomingMap)
traits[k] = existingMap
} else {
traits[k] = incomingMap // New namespace
@@ -2059,7 +2207,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
allEmails := []string{userEmail}
if secondaryRaw, exists := traits["sub_email"]; exists {
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
if secondaryEmails, ok := secondaryRaw.([]any); ok {
for _, se := range secondaryEmails {
if seStr, ok := se.(string); ok {
allEmails = append(allEmails, seStr)
@@ -2198,6 +2346,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "user id is required")
}
slog.Info("[UserHandler] Attempting to delete user", "userID", userID)
// [New] Check access scope before deletion
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
@@ -2228,13 +2378,44 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
}
}
// [FIX] Support fixed UUID deletion
// If userID is a fixed UUID, it might not be the Kratos internal ID.
actualKratosID := userID
if h.KratosAdmin != nil {
// 1. Try finding by identifier (which checks external_id if it's a UUID)
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), userID)
if err == nil && id != "" {
actualKratosID = id
slog.Info("[UserHandler] Mapped userID to Kratos identity ID", "userID", userID, "actualKratosID", actualKratosID)
} else {
// 2. Fallback: If not found by ID/ExternalID, try finding by EMAIL from local DB
if h.UserRepo != nil {
local, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && local != nil && local.Email != "" {
id, err := h.KratosAdmin.FindIdentityIDByIdentifier(c.Context(), local.Email)
if err == nil && id != "" {
actualKratosID = id
slog.Info("[UserHandler] Mapped userID to Kratos identity ID via email", "userID", userID, "email", local.Email, "actualKratosID", actualKratosID)
}
}
}
}
}
if err := h.enqueueDeletedUserRelyingPartyCleanup(c.Context(), requester, userID); err != nil {
slog.Error("[UserHandler] Failed to enqueue RP cleanup", "userID", userID, "error", err)
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
if err := h.KratosAdmin.DeleteIdentity(c.Context(), userID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
if err := h.KratosAdmin.DeleteIdentity(c.Context(), actualKratosID); err != nil {
slog.Error("[UserHandler] Failed to delete Kratos identity", "userID", userID, "actualKratosID", actualKratosID, "error", err)
// If Kratos says 404, it might already be gone, so we can proceed to cleanup local DB
if !strings.Contains(err.Error(), "404") {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
slog.Info("[UserHandler] Successfully deleted Kratos identity", "userID", userID, "actualKratosID", actualKratosID)
if h.Worksmobile != nil && identity != nil {
localUser := h.mapToLocalUser(*identity)
if err := h.Worksmobile.EnqueueUserDeleteIfInScope(c.Context(), *localUser); err != nil {
@@ -2259,6 +2440,8 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
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)
} else {
slog.Info("[UserHandler] Successfully deleted local user read-model", "userID", userID)
}
}
@@ -2387,7 +2570,7 @@ func (h *UserHandler) listDeletedUserRelyingPartyRelations(ctx context.Context,
var tuples []service.RelationTuple
var err error
for attempt := 0; attempt < 3; attempt++ {
for attempt := range 3 {
tuples, err = h.KetoService.ListRelations(ctx, "RelyingParty", "", "", subject)
if err != nil {
return nil, err
@@ -2427,6 +2610,21 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
traits := identity.Traits
role := roleFromTraits(traits)
// [FIX] Prioritize Local DB ID (the fixed UUID from user)
finalID := identity.ID
email := extractTraitString(traits, "email")
if h.UserRepo != nil && email != "" {
// 1. Try finding by email first as it's a strong identifier
if local, err := h.UserRepo.FindByEmail(ctx, email); err == nil && local != nil {
finalID = local.ID
} else if identity.ExternalID != "" {
// 2. Try finding by ID directly (in case ID was fixed to ExternalID)
if local, err := h.UserRepo.FindByID(ctx, identity.ExternalID); err == nil && local != nil {
finalID = local.ID
}
}
}
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := ""
var tenantSummary *domain.Tenant
@@ -2440,7 +2638,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok {
if ids, ok := raw.([]interface{}); ok {
if ids, ok := raw.([]any); ok {
for _, id := range ids {
if s, ok := id.(string); ok {
customLoginIDs = append(customLoginIDs, s)
@@ -2452,7 +2650,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
}
summary := userSummary{
ID: identity.ID,
ID: finalID,
Email: extractTraitString(traits, "email"),
LoginID: resolvePasswordLoginID(traits),
CustomLoginIDs: customLoginIDs,
@@ -2512,8 +2710,15 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
traits := identity.Traits
role := roleFromTraits(traits)
// [FIX] Prioritize ExternalID as the primary ID for Baron SSO if it exists.
// This ensures that admin-provided UUIDs are kept consistent across async syncs.
finalID := identity.ID
if identity.ExternalID != "" {
finalID = identity.ExternalID
}
user := &domain.User{
ID: identity.ID,
ID: finalID,
Email: extractTraitString(traits, "email"),
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
@@ -2637,7 +2842,7 @@ func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole
}
}
func extractTraitString(traits map[string]interface{}, key string) string {
func extractTraitString(traits map[string]any, key string) string {
if traits == nil {
return ""
}
@@ -2649,12 +2854,12 @@ func extractTraitString(traits map[string]interface{}, key string) string {
return ""
}
func extractTraitStringArray(traits map[string]interface{}, key string) []string {
func extractTraitStringArray(traits map[string]any, key string) []string {
if traits == nil {
return nil
}
if raw, ok := traits[key]; ok {
if slice, ok := raw.([]interface{}); ok {
if slice, ok := raw.([]any); ok {
var result []string
for _, v := range slice {
if s, ok := v.(string); ok {
@@ -2670,10 +2875,10 @@ func extractTraitStringArray(traits map[string]interface{}, key string) []string
return nil
}
func resolvePasswordLoginID(traits map[string]interface{}) string {
func resolvePasswordLoginID(traits map[string]any) 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 ids, ok := raw.([]any); ok && len(ids) > 0 {
if first, ok := ids[0].(string); ok {
return first
}
@@ -2693,7 +2898,7 @@ func resolvePasswordLoginID(traits map[string]interface{}) string {
// 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 {
func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService, traits map[string]any, metadata map[string]any, userID string) []domain.UserLoginID {
if tenantService == nil {
return nil
}
@@ -2726,13 +2931,13 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
continue
}
schema, ok := tenant.Config["userSchema"].([]interface{})
schema, ok := tenant.Config["userSchema"].([]any)
if !ok {
continue
}
for _, fieldRaw := range schema {
field, ok := fieldRaw.(map[string]interface{})
field, ok := fieldRaw.(map[string]any)
if !ok {
continue
}
@@ -2751,7 +2956,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
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 {
} else if namespaced, ok := metadata[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string)
}
@@ -2761,7 +2966,7 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
if val == "" {
// Check existing trait (namespaced)
if namespaced, ok := traits[tid].(map[string]interface{}); ok {
if namespaced, ok := traits[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string)
} else if namespaced, ok := traits[tid].(map[string]any); ok {
val, _ = namespaced[fieldKey].(string)
@@ -2833,13 +3038,13 @@ func isMetadataMap(value any) bool {
if _, ok := value.(map[string]any); ok {
return true
}
if _, ok := value.(map[string]interface{}); ok {
if _, ok := value.(map[string]any); ok {
return true
}
return false
}
func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
func normalizeCustomLoginIDsTrait(traits map[string]any) {
raw, exists := traits["custom_login_ids"]
if !exists {
return
@@ -2847,7 +3052,7 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
switch values := raw.(type) {
case []string:
return
case []interface{}:
case []any:
normalized := make([]string, 0, len(values))
for _, value := range values {
if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
@@ -2909,14 +3114,14 @@ func normalizePhoneNumber(phone string) string {
return normalized
}
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []interface{}, checkRequired bool) error {
func (h *UserHandler) validateMetadata(metadata map[string]any, schema []any, 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{})
func (h *UserHandler) validateMetadataWithAuth(metadata map[string]any, schema []any, isAdmin bool, checkRequired bool) error {
schemaMap := make(map[string]map[string]any)
for _, s := range schema {
if m, ok := s.(map[string]interface{}); ok {
if m, ok := s.(map[string]any); ok {
if key, ok := m["key"].(string); ok {
schemaMap[key] = m
}