package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "baron-sso-backend/internal/utils" "errors" "log/slog" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type TenantHandler struct { DB *gorm.DB Service service.TenantService UserRepo repository.UserRepository Keto service.KetoService KetoOutbox repository.KetoOutboxRepository KratosAdmin service.KratosAdminService } func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler { return &TenantHandler{ DB: db, Service: svc, UserRepo: userRepo, Keto: keto, KetoOutbox: outbox, KratosAdmin: kratos, } } type tenantSummary struct { ID string `json:"id"` Type string `json:"type"` ParentID *string `json:"parentId"` Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains,omitempty"` Config domain.JSONMap `json:"config,omitempty"` MemberCount int64 `json:"memberCount"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } type tenantListResponse struct { Items []tenantSummary `json:"items"` Limit int `json:"limit"` Offset int `json:"offset"` Total int64 `json:"total"` } func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error { var req struct { Name string `json:"name"` Slug string `json:"slug"` Description string `json:"description"` Domain string `json:"domain"` AdminEmail string `json:"adminEmail"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } // Basic validation if req.Name == "" || req.Domain == "" || req.AdminEmail == "" { return errorJSON(c, fiber.StatusBadRequest, "name, domain, and adminEmail are required") } tenant, err := h.Service.RequestRegistration(c.Context(), req.Name, req.Slug, req.Description, req.Domain, req.AdminEmail) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) } return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ "message": "Registration request received and is pending approval.", "tenant": mapTenantSummary(*tenant), }) } func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } if err := h.Service.ApproveTenant(c.Context(), tenantID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.JSON(fiber.Map{"message": "Tenant approved successfully"}) } func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) parentId := c.Query("parentId") if limit <= 0 { limit = 50 } if offset < 0 { offset = 0 } var tenants []domain.Tenant var total int64 var err error profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) // If Tenant Admin, only list manageable tenants if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleTenantAdmin { slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID) tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID) if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error()) } total = int64(len(tenants)) // Apply basic pagination if needed (optional for usually small number of manageable tenants) if offset < len(tenants) { end := offset + limit if end > len(tenants) { end = len(tenants) } tenants = tenants[offset:end] } else { tenants = []domain.Tenant{} } } else { // Super Admin case tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId) if err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } } // Fetch member counts for all tenants in one query using IDs tenantIDs := make([]string, 0, len(tenants)) slugs := make([]string, 0, len(tenants)) for _, t := range tenants { tenantIDs = append(tenantIDs, t.ID) slugs = append(slugs, t.Slug) } idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), tenantIDs) slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), slugs) items := make([]tenantSummary, 0, len(tenants)) for _, t := range tenants { summary := mapTenantSummary(t) // Combine counts from both ID and Slug (Max to avoid double counting if some have one or the other) idCount := idCounts[t.ID] slugCount := slugCounts[strings.ToLower(t.Slug)] if idCount > slugCount { summary.MemberCount = idCount } else { summary.MemberCount = slugCount } items = append(items, summary) } return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) } func (h *TenantHandler) GetTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } idCounts, _ := h.UserRepo.CountByTenantIDs(c.Context(), []string{tenant.ID}) slugCounts, _ := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug}) idCount := idCounts[tenant.ID] slugCount := slugCounts[strings.ToLower(tenant.Slug)] count := idCount if slugCount > idCount { count = slugCount } summary := mapTenantSummary(tenant) summary.MemberCount = count return c.JSON(summary) } func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } var req struct { Name string `json:"name"` Slug string `json:"slug"` Type string `json:"type"` Description string `json:"description"` Status string `json:"status"` Domains []string `json:"domains"` ParentID *string `json:"parentId"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } name := strings.TrimSpace(req.Name) if name == "" { return errorJSON(c, fiber.StatusBadRequest, "name is required") } tenantType := normalizeTenantType(req.Type) if tenantType == "" { tenantType = domain.TenantTypeCompany // Default to COMPANY } slug := req.Slug if slug == "" { slug = utils.GenerateUniqueSlug(name, func(s string) bool { var count int64 h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count) return count > 0 }) } else { slug = utils.GenerateSlug(slug) } if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug is required") } status := normalizeTenantStatus(req.Status) if status == "" { status = "active" } // Use Service var parentID *string if req.ParentID != nil && strings.TrimSpace(*req.ParentID) != "" { pid := strings.TrimSpace(*req.ParentID) parentID = &pid } // Extract creator ID if present creatorID := "" if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { creatorID = profile.ID } tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID) if err != nil { if strings.Contains(err.Error(), "already exists") { return errorJSON(c, fiber.StatusConflict, err.Error()) } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } summary := mapTenantSummary(*tenant) summary.MemberCount = 0 if req.Config != nil { tenant.Config = req.Config h.DB.Save(tenant) summary.Config = tenant.Config } return c.Status(fiber.StatusCreated).JSON(summary) } func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } var req struct { Name *string `json:"name"` Type *string `json:"type"` Slug *string `json:"slug"` Description *string `json:"description"` Status *string `json:"status"` ParentID *string `json:"parentId"` Domains []string `json:"domains"` Config map[string]any `json:"config"` } if err := c.BodyParser(&req); err != nil { return errorJSON(c, fiber.StatusBadRequest, "invalid request body") } if req.Name != nil { name := strings.TrimSpace(*req.Name) if name == "" { return errorJSON(c, fiber.StatusBadRequest, "name cannot be empty") } tenant.Name = name } if req.Type != nil { tenantType := normalizeTenantType(*req.Type) if tenantType == "" { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"}) } tenant.Type = tenantType } if req.Slug != nil { slug := utils.GenerateSlug(*req.Slug) if slug == "" { return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty") } if slug != tenant.Slug { var exists domain.Tenant if err := h.DB.Unscoped().Where("slug = ?", slug).First(&exists).Error; err == nil { return errorJSON(c, fiber.StatusConflict, "slug already exists") } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } tenant.Slug = slug } } if req.Description != nil { tenant.Description = strings.TrimSpace(*req.Description) } if req.Status != nil { status := normalizeTenantStatus(*req.Status) if status == "" { return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive") } tenant.Status = status } if req.ParentID != nil { pid := strings.TrimSpace(*req.ParentID) if pid == "" { tenant.ParentID = nil } else { // 순환 참조(Circular Dependency) 방지 로직: // 새로운 부모(pid)부터 상위로 탐색하면서 현재 테넌트(tenant.ID)가 나오면 순환 참조로 간주함 checkID := pid for checkID != "" { if checkID == tenant.ID { return errorJSON(c, fiber.StatusConflict, "순환 참조 오류: 하위 테넌트를 상위 테넌트로 지정할 수 없습니다.") } var pTenant domain.Tenant if err := h.DB.Select("id, parent_id").First(&pTenant, "id = ?", checkID).Error; err != nil { break // 데이터를 찾을 수 없거나 에러 발생 시 반복문 종료 (추후 외래키 제약조건 등에서 에러 발생) } if pTenant.ParentID != nil { checkID = *pTenant.ParentID } else { break } } tenant.ParentID = &pid } // [Keto] Sync hierarchy via Outbox if h.KetoOutbox != nil { if tenant.ParentID != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "parents", Subject: "Tenant:" + *tenant.ParentID, Action: domain.KetoOutboxActionCreate, }) } else { // We don't have enough info here to delete specific parent if we don't know the old one, // but for now we focus on adding. } } } if req.Config != nil { tenant.Config = req.Config } if err := h.DB.Save(&tenant).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Update domains if provided if req.Domains != nil { // Simple approach: Delete existing and recreate if err := h.DB.Delete(&domain.TenantDomain{}, "tenant_id = ?", tenant.ID).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to clear old domains") } for _, d := range req.Domains { if strings.TrimSpace(d) == "" { continue } // Use repository for consistency if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to add domain: "+d) } } } // Refetch to get updated relations h.DB.Preload("Domains").First(&tenant, "id = ?", tenant.ID) return c.JSON(mapTenantSummary(tenant)) } func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { if h.DB == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "database not available") } tenantID := strings.TrimSpace(c.Params("id")) if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } var tenant domain.Tenant if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errorJSON(c, fiber.StatusNotFound, "tenant not found") } return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } // Rename slug to release it for reuse before soft delete deletedSlug := tenant.Slug + "-deleted-" + time.Now().Format("20060102150405") if err := h.DB.Model(&tenant).Update("slug", deletedSlug).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, "failed to release slug") } if err := h.DB.Delete(&tenant).Error; err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } // Fetch admins from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } type adminInfo struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } admins := []adminInfo{} for _, rel := range relations { if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") // Fetch user details - Try Kratos first, then local DB name := "Unknown" email := "Unknown" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } else if h.UserRepo != nil { // Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos) user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } admins = append(admins, adminInfo{ ID: userID, Name: name, Email: email, }) } return c.JSON(admins) } func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "User:"+userID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 관리자로 등록된 사용자입니다.") } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) // Also add as member for UI visibility/ReBAC logic _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { if profile.ID == userID { return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from admin role") } } if h.Keto != nil { if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", ""); err == nil { adminCount := 0 isTargetAdmin := false for _, rel := range relations { if strings.HasPrefix(rel.SubjectID, "User:") { adminCount++ if rel.SubjectID == "User:"+userID { isTargetAdmin = true } } } if isTargetAdmin && adminCount <= 1 { return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last admin") } } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "admins", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } return c.SendStatus(fiber.StatusNoContent) } func (h *TenantHandler) ListOwners(c *fiber.Ctx) error { tenantID := c.Params("id") if tenantID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") } // Fetch owners from Keto relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } type ownerInfo struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } owners := []ownerInfo{} for _, rel := range relations { if !strings.HasPrefix(rel.SubjectID, "User:") { continue } userID := strings.TrimPrefix(rel.SubjectID, "User:") // Fetch user details - Try Kratos first, then local DB name := "Unknown" email := "Unknown" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) if err == nil && identity != nil { if n, ok := identity.Traits["name"].(string); ok { name = n } if e, ok := identity.Traits["email"].(string); ok { email = e } } else if h.UserRepo != nil { // Fallback to local DB user, err := h.UserRepo.FindByID(c.Context(), userID) if err == nil && user != nil { name = user.Name email = user.Email } else if userID == "00000000-0000-0000-0000-000000000000" { name = "Dev Mock User" email = "mock@hmac.kr" } } owners = append(owners, ownerInfo{ ID: userID, Name: name, Email: email, }) } return c.JSON(owners) } func (h *TenantHandler) AddOwner(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if h.Keto != nil { relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "User:"+userID) if err == nil && len(relations) > 0 { return errorJSON(c, fiber.StatusConflict, "이미 소유자로 등록된 사용자입니다.") } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "owners", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) // Also add as member for UI visibility/ReBAC logic _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return c.SendStatus(fiber.StatusOK) } func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error { tenantID := c.Params("id") userID := c.Params("userId") if tenantID == "" || userID == "" { return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") } if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { if profile.ID == userID { return errorJSON(c, fiber.StatusBadRequest, "cannot remove yourself from owner role") } } if h.Keto != nil { if relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", ""); err == nil { ownerCount := 0 isTargetOwner := false for _, rel := range relations { if strings.HasPrefix(rel.SubjectID, "User:") { ownerCount++ if rel.SubjectID == "User:"+userID { isTargetOwner = true } } } if isTargetOwner && ownerCount <= 1 { return errorJSON(c, fiber.StatusBadRequest, "cannot remove the last owner") } } } if h.KetoOutbox != nil { _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: "owners", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } return c.SendStatus(fiber.StatusNoContent) } func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { domains = append(domains, d.Domain) } return tenantSummary{ ID: t.ID, Type: t.Type, ParentID: t.ParentID, Name: t.Name, Slug: t.Slug, Description: t.Description, Status: t.Status, Domains: domains, Config: t.Config, CreatedAt: t.CreatedAt.Format(time.RFC3339), UpdatedAt: t.UpdatedAt.Format(time.RFC3339), } } func normalizeTenantStatus(value string) string { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { return "" } if value != "active" && value != "inactive" { return "" } return value } func normalizeTenantType(value string) string { value = strings.ToUpper(strings.TrimSpace(value)) switch value { case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup: return value default: return "" } }