1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/user_handler.go
chan f6cf261fd5 fix: resolve tenant member removal and move aggregation bugs
- adminfront: Update removeMutation to correctly pass 'isRemoveTenant: true' and the specific tenant slug instead of empty string
- backend: Fix 'Move' operation (Normal Update) in UpdateUser to correctly remove the old primary company code from the 'companyCodes' array and sync the deletion to Keto, ensuring accurate member count aggregation
2026-05-07 15:43:08 +09:00

2546 lines
81 KiB
Go

package handler
import (
"baron-sso-backend/internal/domain"
"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
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 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 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
}
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"`
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"`
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"`
}
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"))
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)
}
}
// 1. Try Kratos First
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"))
// Tenant Admin & Member filtering
if requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin {
if !manageableSlugs[compCode] && !manageableSlugs[tID] {
continue
}
}
// Dedicated tenantSlug filter
if tenantSlug != "" && !strings.EqualFold(compCode, tenantSlug) && tID != targetTenantID {
continue
}
// Search filtering (Keyword search in email, name, or companyCode)
if search != "" {
if !strings.Contains(email, searchLower) &&
!strings.Contains(name, searchLower) &&
!strings.Contains(strings.ToLower(compCode), searchLower) {
continue
}
}
filtered = append(filtered, identity)
}
total := int64(len(filtered))
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
items := make([]userSummary, 0, end-offset)
for _, identity := range filtered[offset:end] {
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})
}
// 2. Fallback to Local DB if Kratos is down
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// If requester is not Super Admin, we should technically filter by manageable slugs in DB too.
// For simplicity in fallback, if tenantSlug is empty we default to their primary company code.
if (requesterRole == domain.RoleTenantAdmin || requesterRole == domain.RoleUser || requesterRole == domain.RoleRPAdmin) && tenantSlug == "" {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if profile != nil && profile.CompanyCode != "" {
tenantSlug = profile.CompanyCode
}
}
// Fetch from UserRepo
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
}
items := make([]userSummary, 0, len(users))
for _, u := range users {
items = append(items, userSummary{
ID: u.ID,
Email: u.Email,
Name: u.Name,
Phone: u.Phone,
Role: u.Role,
Status: u.Status,
CompanyCode: u.CompanyCode,
Department: u.Department,
CreatedAt: u.CreatedAt.Format(time.RFC3339),
UpdatedAt: u.UpdatedAt.Format(time.RFC3339),
})
}
return c.JSON(userListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
})
}
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"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
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")
}
req.Metadata = 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,
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
"grade": role,
}
// [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
if req.CompanyCode == "" && h.TenantService != nil {
if primaryTenantID := primaryTenantIDFromRequest(req.PrimaryTenantID, req.Metadata, req.AdditionalAppointments); primaryTenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), primaryTenantID); 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
}
}
// Collect and sync all custom login IDs based on tenant schemas
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
attributes["role"] = role
attributes["companyCode"] = req.CompanyCode
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)
}
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)
}
// [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"`
TenantSlug string `json:"tenantSlug"`
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
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
Schema []interface{}
Groups []domain.UserGroup
LoginIDField string
}
tenantCache := make(map[string]tenantCacheItem)
for _, item := range req.Users {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantSlug := strings.TrimSpace(item.TenantSlug)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
continue
}
if tenantSlug == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenantSlug is required"})
continue
}
// 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
}
}
// Verify Tenant Existence and Resolve ID (with Cache)
var tItem tenantCacheItem
var exists bool
if tItem, exists = tenantCache[tenantSlug]; !exists {
if h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
if err != nil || tenant == nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid tenantSlug: tenant not found"})
continue
}
tItem.ID = tenant.ID
if s, ok := tenant.Config["userSchema"].([]interface{}); ok {
tItem.Schema = s
}
if lf, ok := tenant.Config["loginIdField"].(string); ok {
tItem.LoginIDField = lf
}
// [Fix] Cache user groups for this tenant to match department
if h.UserGroupRepo != nil {
if groups, err := h.UserGroupRepo.ListByTenantID(c.Context(), tenant.ID); err == nil {
tItem.Groups = groups
}
}
tenantCache[tenantSlug] = tItem
} else {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "tenant service unavailable"})
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
}
}
password, _ := utils.GeneratePasswordWithPolicy(policy)
role := item.Role
if role == "" {
role = "user"
}
attributes := map[string]interface{}{
"department": dept,
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"grade": role,
"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,
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)
}
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)
}
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"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if companyCode == "" {
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
}
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
// repo.List expects (ctx, offset, limit, search, companyCode)
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, companyCode)
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", "Position", "JobTitle", "CreatedAt"}
if includeIDs {
header = []string{"user_id", "Email", "Name", "Phone", "Status", "tenant_id", "tenant_slug", "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.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.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"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Position *string `json:"position"`
JobTitle *string `json:"jobTitle"`
}
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")
}
// [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
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if requester.Role == domain.RoleTenantAdmin {
if !manageableSlugs[userComp] {
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 {
traits["companyCode"] = *req.CompanyCode
// 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.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 := extractTraitString(identity.Traits, "grade")
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 {
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if !manageableSlugs[userComp] {
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 {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
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"`
CompanyCode *string `json:"companyCode"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"`
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")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
// [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{}{}
}
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
existingCodes = append(existingCodes, str)
}
}
}
// Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응)
if len(existingCodes) <= 1 && h.TenantService != nil {
if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil {
for _, t := range joined {
existingCodes = append(existingCodes, t.Slug)
}
}
}
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.IsAddTenant {
// Add to existingCodes if not present
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
} else if req.IsRemoveTenant {
// Remove from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != code {
newCodes = append(newCodes, existing)
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the target tenant
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 removing the primary company code, pick another one as primary if available
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary == code {
if len(existingCodes) > 0 {
traits["companyCode"] = existingCodes[0]
if h.TenantService != nil {
if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
traits["tenant_id"] = t.ID
}
}
} else {
traits["companyCode"] = ""
traits["tenant_id"] = ""
}
}
} else {
// Normal update (Move): replace primary company code and remove the old one from existingCodes
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary != "" && currentPrimary != code {
// Remove old primary from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != currentPrimary {
newCodes = append(newCodes, existing)
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the old tenant
if h.TenantService != nil && h.KetoOutboxRepo != nil {
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,
})
}
}(currentPrimary)
}
}
traits["companyCode"] = code
// Resolve TenantID for Kratos Trait
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID
}
}
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
} }
// Deduplicate and save back companyCodes
var codesToSave []string
seenCodes := map[string]bool{}
for _, c := range existingCodes {
if !seenCodes[c] && c != "" {
seenCodes[c] = true
codesToSave = append(codesToSave, c)
}
}
if len(codesToSave) > 0 {
traits["companyCodes"] = codesToSave
} else {
delete(traits, "companyCodes")
}
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
}
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["grade"] = role
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)
}
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)
}
// [Keto Sync] asynchronously as it's less critical for immediate UI count
go func() {
bgCtx := context.Background()
h.syncKetoRole(bgCtx, updatedLocalUser.ID,
extractTraitString(updated.Traits, "grade"), 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 {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
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
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.KratosIdentity) userSummary {
traits := identity.Traits
role := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
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),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
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, "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
}
if compCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant
}
}
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 := extractTraitString(traits, "grade")
if role == "" {
role = extractTraitString(traits, "role")
}
role = domain.NormalizeRole(role)
if role == "" {
role = domain.RoleUser
}
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
}
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),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Position: extractTraitString(traits, "position"),
JobTitle: extractTraitString(traits, "jobTitle"),
AffiliationType: extractTraitString(traits, "affiliationType"),
CreatedAt: identity.CreatedAt,
UpdatedAt: identity.UpdatedAt,
}
// [New] Sync multi-tenant codes
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
user.CompanyCodes = append(user.CompanyCodes, str)
}
}
} else if codes, ok := traits["companyCodes"].([]string); ok {
user.CompanyCodes = codes
}
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id")
if tID != "" {
user.TenantID = &tID
}
// 2. Fallback to slug lookup only if tenant_id trait is missing
if (user.TenantID == nil || *user.TenantID == "") && compCode != "" && h.TenantService != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
user.TenantID = &tenant.ID
}
}
// 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, "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 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)
}