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" "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 } func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProviderAPI, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler { return &UserHandler{ KratosAdmin: kratosAdmin, OryProvider: oryProvider, TenantService: tenantService, KetoService: ketoService, KetoOutboxRepo: ketoOutboxRepo, UserRepo: userRepo, } } type userSummary struct { ID string `json:"id"` Email string `json:"email"` 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"` Department string `json:"department"` 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 = profile.Role } limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) search := strings.TrimSpace(c.Query("search")) companyCode := strings.TrimSpace(c.Query("companyCode")) 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 { profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if profile != nil { for _, t := range profile.ManageableTenants { manageableSlugs[strings.ToLower(t.Slug)] = true } // Include primary tenant slug if not already there if profile.CompanyCode != "" { manageableSlugs[strings.ToLower(profile.CompanyCode)] = true } } } // 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")) // Tenant Admin filtering if requesterRole == domain.RoleTenantAdmin { if !manageableSlugs[compCode] { continue } } // Dedicated companyCode filter if companyCode != "" && !strings.EqualFold(compCode, companyCode) { 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) // Fetch from UserRepo users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode) 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 := extractTraitString(identity.Traits, "companyCode") if requester.CompanyCode == "" || compCode != requester.CompanyCode { 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"` Password string `json:"password"` Name string `json:"name"` Phone string `json:"phone"` Role string `json:"role"` CompanyCode string `json:"companyCode"` Department string `json:"department"` Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } 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 := strings.TrimSpace(req.Role) if role == "" { role = "user" } attributes := map[string]interface{}{ "department": req.Department, "affiliationType": "internal", "companyCode": req.CompanyCode, "grade": role, } // [Resolve TenantID before Kratos creation] var tenantID string if req.CompanyCode != "" && h.TenantService != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode); err == nil && tenant != nil { tenantID = tenant.ID } } attributes["role"] = role if tenantID != "" { attributes["tenant_id"] = tenantID } // Merge custom metadata into attributes for k, v := range req.Metadata { // Don't overwrite core fields if _, exists := attributes[k]; !exists { attributes[k] = v } } brokerUser := &domain.BrokerUser{ Email: email, Name: name, PhoneNumber: normalizePhoneNumber(req.Phone), Attributes: attributes, } // [Validation] Based on Tenant Schema if req.CompanyCode != "" && h.TenantService != nil { tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) 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(), "already exists") { return errorJSON(c, fiber.StatusConflict, "email already exists") } 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 go func(u *domain.User, role string, tID *string) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Use Update (upsert) instead of Create for robustness if err := h.UserRepo.Update(ctx, u); err != nil { slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err) return } // [Keto] Sync relations via Outbox if h.KetoOutboxRepo != nil { h.syncKetoRole(ctx, u.ID, role, "", "", tID) } }(localUser, role, localUser.TenantID) } 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"` Name string `json:"name"` Phone string `json:"phone"` Role string `json:"role"` CompanyCode string `json:"companyCode"` Department string `json:"department"` Metadata map[string]any `json:"metadata"` } type bulkUserResult struct { Email string `json:"email"` 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)) // Pre-fetch tenant data to avoid redundant DB calls type tenantCacheItem struct { ID string Schema []interface{} } tenantCache := make(map[string]tenantCacheItem) for _, item := range req.Users { email := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) compCode := strings.TrimSpace(item.CompanyCode) dept := strings.TrimSpace(item.Department) if email == "" || name == "" { results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"}) continue } if compCode == "" { results = append(results, bulkUserResult{Email: email, Success: false, Message: "companyCode (tenant) is required"}) continue } // Role-based access check if requester != nil && requester.Role == domain.RoleTenantAdmin { if compCode != 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[compCode]; !exists { if h.TenantService != nil { tenant, err := h.TenantService.GetTenantBySlug(c.Context(), compCode) if err != nil || tenant == nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: "invalid companyCode: tenant not found"}) continue } tItem.ID = tenant.ID if s, ok := tenant.Config["userSchema"].([]interface{}); ok { tItem.Schema = s } tenantCache[compCode] = 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 } } password, _ := utils.GeneratePasswordWithPolicy(policy) role := item.Role if role == "" { role = "user" } attributes := map[string]interface{}{ "department": dept, "affiliationType": "internal", "companyCode": compCode, "tenant_id": tItem.ID, "grade": role, "role": role, } // Merge metadata for k, v := range item.Metadata { if _, exists := attributes[k]; !exists { attributes[k] = v } } identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ Email: email, Name: item.Name, PhoneNumber: normalizePhoneNumber(item.Phone), Attributes: attributes, }, password) if err != nil { results = append(results, bulkUserResult{Email: email, Success: false, Message: err.Error()}) continue } // Sync to local DB if h.UserRepo != nil { identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID) if identity != nil { localUser := h.mapToLocalUser(*identity) _ = h.UserRepo.Update(context.Background(), localUser) if h.KetoOutboxRepo != nil { h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID) } } } results = append(results, bulkUserResult{Email: email, 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")) var requesterRole string var manageableSlugs []string var profile *domain.UserProfileResponse // [New] Manual profile resolution to support query-param role mocking // This is needed because browsers cannot send custom headers for direct downloads mockRole := c.Query("x-test-role") appEnv := strings.ToLower(os.Getenv("APP_ENV")) isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" if isDev && mockRole != "" { slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole) requesterRole = mockRole // In dev mocking, we might not have a full profile, but we need to know the manageable tenants if it's a tenant_admin if requesterRole == domain.RoleTenantAdmin { // Try to get actual profile if possible to get manageableTenants p, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if p != nil { profile = p } } } else { // Use real profile from middleware p, ok := c.Locals("user_profile").(*domain.UserProfileResponse) if !ok || p == nil { return errorJSON(c, fiber.StatusUnauthorized, "invalid session (trace:export_auth)") } profile = p requesterRole = profile.Role } // [New] Access Control: only admin roles can export if requesterRole != domain.RoleSuperAdmin && requesterRole != domain.RoleTenantAdmin { return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions for export") } if profile != nil && requesterRole == domain.RoleTenantAdmin { for _, t := range profile.ManageableTenants { manageableSlugs = append(manageableSlugs, strings.ToLower(t.Slug)) } if profile.CompanyCode != "" { manageableSlugs = append(manageableSlugs, strings.ToLower(profile.CompanyCode)) } } // 1. Fetch Users using Repo for efficiency users, _, err := h.UserRepo.List(c.Context(), 10000, 0, 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") c.Set("Content-Disposition", "attachment; filename=users_export_"+time.Now().Format("20060102")+".csv") writer := csv.NewWriter(c) defer writer.Flush() // Header row header := []string{"ID", "Email", "Name", "Role", "Status", "Tenant", "Department", "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 { 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 { row := []string{ u.ID, u.Email, u.Name, u.Role, u.Status, u.CompanyCode, u.Department, 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 (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"` } 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 { 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 } 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) 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 } _ = h.UserRepo.Update(c.Context(), localUser) } 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 { 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 } // 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 if h.UserRepo != nil { if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil { oldRole = local.Role if local.TenantID != nil { oldTenantID = *local.TenantID } } } // [New] Check access scope requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) if requester != nil && 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 { Password *string `json:"password"` Name *string `json:"name"` Phone *string `json:"phone"` Role *string `json:"role"` Status *string `json:"status"` CompanyCode *string `json:"companyCode"` Department *string `json:"department"` Metadata map[string]any `json:"metadata"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // [New] Tenant Admin restriction: Cannot change companyCode if requester != nil && requester.Role == domain.RoleTenantAdmin { if 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 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 { isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin) if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil { return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error()) } } } } traits := identity.Traits if traits == nil { traits = map[string]interface{}{} } 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) 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 } } } if req.Department != nil { traits["department"] = strings.TrimSpace(*req.Department) } if req.Role != nil { role := strings.TrimSpace(*req.Role) if role == "" { role = "user" } traits["grade"] = role traits["role"] = role } // [Refined] Metadata synchronization: replace non-core traits with new Metadata coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, "affiliationType": true, "role": true, "tenant_id": true, } // 1. Remove existing non-core traits to handle deletions for k := range traits { if !coreTraits[k] { delete(traits, k) } } // 2. Add new metadata fields for k, v := range req.Metadata { if !coreTraits[k] { traits[k] = v } } state := normalizeKratosState(req.Status) updated, err := h.KratosAdmin.UpdateIdentity(c.Context(), userID, traits, state) if err != nil { 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) 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) } // [Keto Sync] asynchronously as it's less critical for immediate UI count go h.syncKetoRole(context.Background(), updatedLocalUser.ID, extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID) } if req.Password != nil && *req.Password != "" { if err := h.KratosAdmin.UpdateIdentityPassword(c.Context(), userID, *req.Password); 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) if requester != nil && requester.Role == domain.RoleTenantAdmin { identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && 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()) } // [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 = "user" } compCode := extractTraitString(traits, "companyCode") slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode) summary := userSummary{ 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"), Metadata: make(domain.JSONMap), CreatedAt: formatTime(identity.CreatedAt), UpdatedAt: formatTime(identity.UpdatedAt), } // Filter out core traits and put everything else in Metadata coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, "affiliationType": true, } for k, v := range traits { if !coreTraits[k] { 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 = "user" } compCode := extractTraitString(traits, "companyCode") 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"), AffiliationType: extractTraitString(traits, "affiliationType"), CreatedAt: identity.CreatedAt, UpdatedAt: identity.UpdatedAt, } if compCode != "" && h.TenantService != nil { // Use a background context or a timeout-limited context for tenant lookup 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 user.Metadata = make(domain.JSONMap) coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "companyCode": true, "department": true, "affiliationType": true, "role": true, "tenant_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) { // Remove old roles if oldRole == domain.RoleSuperAdmin { _ = 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 { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "System", Object: "global", Relation: "super_admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } else if newRole == domain.RoleTenantAdmin && newTenantID != nil { _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: *newTenantID, Relation: "admins", 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 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 == "inactive" || state == "blocked" || state == "active" { return state } if state == "" { return "active" } return state } func normalizeKratosState(status *string) string { if status == nil { return "" } value := strings.ToLower(strings.TrimSpace(*status)) if value == "blocked" { return "inactive" } if value == "active" || value == "inactive" { return value } 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") } // 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 }