forked from baron/baron-sso
merge: integrate origin dev into dev
Includes Worksmobile SSOT sync comparison updates, UUID import conflict resolution, and Playwright route mock stabilization.
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
@@ -203,7 +204,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
|
||||
}
|
||||
@@ -221,7 +222,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 ""
|
||||
@@ -267,7 +268,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)
|
||||
@@ -525,10 +526,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])
|
||||
@@ -580,11 +578,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
|
||||
@@ -687,7 +706,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,
|
||||
@@ -777,7 +796,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())
|
||||
}
|
||||
@@ -853,6 +872,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
|
||||
type bulkUserItem struct {
|
||||
UserID string `json:"userId"`
|
||||
ID string `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
LoginID string `json:"loginId"`
|
||||
Name string `json:"name"`
|
||||
@@ -879,6 +900,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 {
|
||||
@@ -916,7 +938,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Schema []interface{}
|
||||
Schema []any
|
||||
Groups []domain.UserGroup
|
||||
LoginIDField string
|
||||
}
|
||||
@@ -930,7 +952,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 {
|
||||
@@ -1016,6 +1038,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
for index, item := range req.Users {
|
||||
var identityID string
|
||||
email := strings.TrimSpace(item.Email)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
tenantID := strings.TrimSpace(item.TenantID)
|
||||
@@ -1026,6 +1049,15 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(item.ID) != "" || strings.TrimSpace(item.UUID) != "" || strings.TrimSpace(item.UserID) != "" {
|
||||
results = append(results, bulkUserResult{
|
||||
Email: email,
|
||||
Success: false,
|
||||
Status: "blockingError",
|
||||
Message: "사용자 UUID 가져오기는 지원하지 않습니다. UUID 보존 복구는 백업/복구 기능을 사용하세요.",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if tenantSlugErr != nil {
|
||||
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
|
||||
continue
|
||||
@@ -1209,7 +1241,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),
|
||||
@@ -1237,7 +1269,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
// 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)
|
||||
@@ -1260,33 +1292,91 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{
|
||||
ID: strings.TrimSpace(item.UserID),
|
||||
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"
|
||||
if identityID == "" {
|
||||
var err 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: "이미 다른 사용자가 해당 식별자(이메일/사번 등)를 사용 중입니다."})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// [CRITICAL FIX] Sync to local DB directly using current data
|
||||
// Don't fetch from Kratos here as it might have propagation lag
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetLocalID := identityID
|
||||
|
||||
if h.UserRepo != nil {
|
||||
localUser := &domain.User{
|
||||
ID: identityID,
|
||||
ID: targetLocalID,
|
||||
Email: userEmail,
|
||||
Name: name,
|
||||
Phone: normalizePhoneNumber(item.Phone),
|
||||
@@ -1305,9 +1395,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)
|
||||
@@ -1323,9 +1411,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 {
|
||||
@@ -1365,10 +1474,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1585,7 +1695,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)
|
||||
|
||||
@@ -1924,7 +2034,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())
|
||||
@@ -1945,7 +2055,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())
|
||||
}
|
||||
@@ -1957,7 +2067,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
|
||||
traits := identity.Traits
|
||||
if traits == nil {
|
||||
traits = map[string]interface{}{}
|
||||
traits = map[string]any{}
|
||||
}
|
||||
if req.Email != nil {
|
||||
currentEmail := strings.TrimSpace(extractTraitString(traits, "email"))
|
||||
@@ -2071,10 +2181,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
|
||||
@@ -2102,7 +2210,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)
|
||||
@@ -2241,6 +2349,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)
|
||||
|
||||
@@ -2271,13 +2381,40 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
actualKratosID := userID
|
||||
if h.KratosAdmin != nil {
|
||||
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 {
|
||||
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 {
|
||||
@@ -2302,6 +2439,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2430,7 +2569,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
|
||||
@@ -2470,6 +2609,16 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
tenantID := extractTraitString(traits, "tenant_id")
|
||||
tenantSlug := ""
|
||||
var tenantSummary *domain.Tenant
|
||||
@@ -2483,7 +2632,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)
|
||||
@@ -2495,7 +2644,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,
|
||||
@@ -2555,8 +2704,10 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
|
||||
traits := identity.Traits
|
||||
role := roleFromTraits(traits)
|
||||
|
||||
finalID := identity.ID
|
||||
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
ID: finalID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
@@ -2680,7 +2831,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 ""
|
||||
}
|
||||
@@ -2692,12 +2843,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 {
|
||||
@@ -2713,10 +2864,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
|
||||
}
|
||||
@@ -2736,7 +2887,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
|
||||
}
|
||||
@@ -2769,13 +2920,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
|
||||
}
|
||||
@@ -2794,7 +2945,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)
|
||||
}
|
||||
|
||||
@@ -2804,7 +2955,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)
|
||||
@@ -2876,13 +3027,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
|
||||
@@ -2890,7 +3041,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) != "" {
|
||||
@@ -3102,14 +3253,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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user