forked from baron/baron-sso
Changes the /v1/admin/tenants endpoint to be accessible by all authenticated users (requireAnyUser). In the handler, it dynamically resolves the user's affiliations and filters the response to return the complete hierarchical tree (root, parent, child, sibling nodes) for any tenant they belong to.
868 lines
24 KiB
Go
868 lines
24 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"
|
|
"errors"
|
|
"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)
|
|
role := ""
|
|
if profile != nil {
|
|
role = domain.NormalizeRole(profile.Role)
|
|
}
|
|
|
|
if role != domain.RoleSuperAdmin {
|
|
// Not a super admin: Only return the entire tree(s) of the tenants they belong to
|
|
allTenants, _, err := h.Service.ListTenants(c.Context(), 10000, 0, "")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
if profile != nil {
|
|
baseTenantIDs := []string{}
|
|
for _, t := range profile.ManageableTenants {
|
|
baseTenantIDs = append(baseTenantIDs, t.ID)
|
|
}
|
|
for _, t := range profile.JoinedTenants {
|
|
baseTenantIDs = append(baseTenantIDs, t.ID)
|
|
}
|
|
if profile.TenantID != nil {
|
|
baseTenantIDs = append(baseTenantIDs, *profile.TenantID)
|
|
}
|
|
|
|
// Try to find by companyCode if needed
|
|
if profile.CompanyCode != "" {
|
|
for _, t := range allTenants {
|
|
if strings.EqualFold(t.Slug, profile.CompanyCode) {
|
|
baseTenantIDs = append(baseTenantIDs, t.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
parentMap := make(map[string]string)
|
|
for _, t := range allTenants {
|
|
if t.ParentID != nil {
|
|
parentMap[t.ID] = *t.ParentID
|
|
}
|
|
}
|
|
|
|
findRoot := func(id string) string {
|
|
curr := id
|
|
for {
|
|
p, exists := parentMap[curr]
|
|
if !exists || p == "" {
|
|
break
|
|
}
|
|
curr = p
|
|
}
|
|
return curr
|
|
}
|
|
|
|
roots := make(map[string]bool)
|
|
for _, id := range baseTenantIDs {
|
|
roots[findRoot(id)] = true
|
|
}
|
|
|
|
// Filter tenants that belong to the same tree family
|
|
for _, t := range allTenants {
|
|
if roots[findRoot(t.ID)] {
|
|
tenants = append(tenants, t)
|
|
}
|
|
}
|
|
}
|
|
|
|
total = int64(len(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 (h *TenantHandler) DeleteTenantsBulk(c *fiber.Ctx) error {
|
|
var req struct {
|
|
IDs []string `json:"ids"`
|
|
}
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
|
}
|
|
|
|
if len(req.IDs) == 0 {
|
|
return errorJSON(c, fiber.StatusBadRequest, "no IDs provided")
|
|
}
|
|
|
|
// Permission check: Super Admin can delete anything.
|
|
// Tenant Admin should theoretically only delete manageable sub-tenants,
|
|
// but currently bulk delete is intended for Super Admin.
|
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
|
if profile == nil || domain.NormalizeRole(profile.Role) != domain.RoleSuperAdmin {
|
|
return errorJSON(c, fiber.StatusForbidden, "only super admin can perform bulk deletion")
|
|
}
|
|
|
|
if err := h.Service.DeleteTenantsBulk(c.Context(), req.IDs); err != nil {
|
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"message": "Tenants deleted successfully",
|
|
"count": len(req.IDs),
|
|
})
|
|
}
|
|
|
|
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 ""
|
|
}
|
|
}
|