1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/handler/user_handler.go

1271 lines
37 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"
"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
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
// [New] Support Role Mocking for Download (which doesn't have custom headers)
if profile == nil {
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
mockRole := c.Query("x-test-role")
if isDev && mockRole != "" {
slog.Info("🔑 [AUTH] Using mock role from query for export", "role", mockRole)
requesterRole = mockRole
// For tenant_admin, we might need more data, but let's assume super_admin for full export in dev
} else {
return errorJSON(c, fiber.StatusUnauthorized, "invalid session (trace:export_profile)")
}
} else {
requesterRole = profile.Role
if 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"`
}
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")
}
// Build manageable slugs map if tenant_admin
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: user belongs to another tenant"})
continue
}
}
// Prepare updates
traits := identity.Traits
if req.Role != nil {
traits["role"] = *req.Role
}
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
}
_ = 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
}