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

703 lines
20 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"
"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 && 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 {
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.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 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.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 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 ""
}
}