From 5cb713a0095c2fc105cfc7f7d525fd283a6e0f28 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 15:10:36 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=A0=84=EB=9E=B5=20?= =?UTF-8?q?=EC=88=98=EB=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 2 +- backend/internal/bootstrap/tenant_seed.go | 5 +-- backend/internal/handler/auth_handler.go | 30 ++++++++++++---- backend/internal/handler/tenant_handler.go | 27 +++++++++----- .../internal/repository/tenant_repository.go | 6 ++-- backend/internal/service/tenant_service.go | 35 ++++++++++++------- backend/internal/utils/slug.go | 25 +++++++++++++ 7 files changed, 95 insertions(+), 35 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3160cd03..3d4612cc 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -249,7 +249,7 @@ func main() { kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() - tenantService := service.NewTenantService(tenantRepo) + tenantService := service.NewTenantService(tenantRepo, userRepo) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService) tenantService.SetKetoService(ketoService) // Keto 주입 diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index 26e5d98f..2500720a 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -30,7 +30,8 @@ var defaultTenants = []InitialTenantConfig{ func SeedTenants(db *gorm.DB) error { slog.Info("[Bootstrap] Seeding initial tenants...") repo := repository.NewTenantRepository(db) - svc := service.NewTenantService(repo) + userRepo := repository.NewUserRepository(db) + svc := service.NewTenantService(repo, userRepo) ctx := context.Background() for _, config := range defaultTenants { @@ -48,7 +49,7 @@ func SeedTenants(db *gorm.DB) error { } if !found { slog.Info("[Bootstrap] Adding missing domain to tenant", "slug", config.Slug, "domain", d) - if err := repo.AddDomain(ctx, existing.ID, d); err != nil { + if err := repo.AddDomain(ctx, existing.ID, d, true); err != nil { slog.Error("Failed to add domain", "error", err) } } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 893e89a4..d65803ac 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -388,8 +388,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Identity provider unavailable"}) } - // [Strict] Enforce Tenant Auto-Assignment by Domain ONLY - // Manual companyCode from request is ignored to prevent unauthorized tenant joining + // [Strict] Enforce Tenant Auto-Assignment companyCode := "" var tenantID *string @@ -399,19 +398,36 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) if err == nil && tenant != nil { if tenant.Status == domain.TenantStatusActive { - slog.Info("[Signup] Auto-assigning tenant", "email", req.Email, "tenant", tenant.Slug) + slog.Info("[Signup] Auto-assigning tenant by domain", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug tenantID = &tenant.ID } else { - slog.Warn("[Signup] Attempted to join non-active tenant", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) - // Policy: If tenant exists but not active, reject signup or allow as general? - // For now, let's allow as general but log it. - // Or return error if we want strict domain locking. + slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Your organization's tenant is currently not active."}) } } } + // Fallback/Validation for manually provided CompanyCode if domain lookup didn't yield a tenant + if tenantID == nil && req.CompanyCode != "" { + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) + if err == nil && tenant != nil { + if tenant.Status == domain.TenantStatusActive { + // Policy: Should we allow manual joining by Slug? + // For now, let's allow it but log it as manual. + slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) + companyCode = tenant.Slug + tenantID = &tenant.ID + } else { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "The specified organization is not active."}) + } + } else { + // If companyCode provided but not found, we should probably reject if we want strictness, + // or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject. + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid company code."}) + } + } + // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 8e70d4af..956e7696 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -2,6 +2,7 @@ package handler import ( "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" "baron-sso-backend/internal/service" "errors" "strings" @@ -272,14 +273,8 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error { if strings.TrimSpace(d) == "" { continue } - td := domain.TenantDomain{ - TenantID: tenant.ID, - Domain: strings.TrimSpace(d), - Verified: true, - } - if err := h.DB.Create(&td).Error; err != nil { - // Log and continue or return error? - // For now return error to be safe. + // Use repository for consistency + if err := repository.NewTenantRepository(h.DB).AddDomain(c.Context(), tenant.ID, strings.TrimSpace(d), true); err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to add domain: " + d}) } } @@ -301,7 +296,21 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenant id is required"}) } - if err := h.DB.Delete(&domain.Tenant{}, "id = ?", tenantID).Error; err != nil { + var tenant domain.Tenant + if err := h.DB.First(&tenant, "id = ?", tenantID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"}) + } + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": 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 c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to release slug"}) + } + + if err := h.DB.Delete(&tenant).Error; err != nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 5d1e3185..cc20a6b5 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -15,7 +15,7 @@ type TenantRepository interface { FindByName(ctx context.Context, name string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) - AddDomain(ctx context.Context, tenantID string, domainName string) error + AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error } type tenantRepository struct { @@ -82,11 +82,11 @@ func (r *tenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domai return tenants, nil } -func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { +func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { td := domain.TenantDomain{ TenantID: tenantID, Domain: domainName, - Verified: true, // Auto-verify for internal init/admin usage for now + Verified: verified, } return r.db.WithContext(ctx).Create(&td).Error } diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 1e187cd0..cebbca1a 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -23,12 +23,13 @@ type TenantService interface { } type tenantService struct { - repo repository.TenantRepository - keto KetoService + repo repository.TenantRepository + userRepo repository.UserRepository + keto KetoService } -func NewTenantService(repo repository.TenantRepository) TenantService { - return &tenantService{repo: repo} +func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository) TenantService { + return &tenantService{repo: repo, userRepo: userRepo} } func (s *tenantService) SetKetoService(keto KetoService) { @@ -136,7 +137,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript // 3. Add Domains (Auto-verify for manual admin registration) for _, d := range domains { - if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil { + if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil { slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err) } } @@ -169,10 +170,7 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des } // Add Domain as unverified - // TODO: Create a more nuanced AddDomain that takes 'verified' param - // For now, Repo.AddDomain sets verified=true. I should fix Repo or just manually do it here if needed. - // Let's fix Repo later. - if err := s.repo.AddDomain(ctx, tenant.ID, domainName); err != nil { + if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); err != nil { return nil, err } @@ -192,12 +190,23 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { // [Keto] Sync relation if s.keto != nil { - // 테넌트 자체를 정의 (Zanzibar style) - // 만약 신청 시 관리자 이메일이 있었다면 해당 사용자를 찾아 admin 권한 부여 시도 if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) - // 여기서는 나중에 사용자가 가입할 때 처리하거나, 이미 가입된 사용자인지 확인 필요 - // 우선 테넌트 관리자 관계 생성 로직은 사용자 가입/역할 변경 시점에 주로 발생하도록 설계 + // Check if user already exists in our Read-Model + if s.userRepo != nil { + user, err := s.userRepo.FindByEmail(ctx, adminEmail) + if err == nil && user != nil { + // User exists, assign Admin role in Keto + err = s.keto.CreateRelation(ctx, "Tenant", tenant.ID, "admin", "User:"+user.ID) + if err != nil { + slog.Error("Failed to assign tenant admin in Keto", "tenant", tenant.ID, "user", user.ID, "error", err) + } else { + slog.Info("Assigned tenant admin in Keto", "tenant", tenant.ID, "user", user.ID) + } + } else { + slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail) + } + } } } diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go index b618dcea..79b57d28 100644 --- a/backend/internal/utils/slug.go +++ b/backend/internal/utils/slug.go @@ -25,6 +25,31 @@ var ( "tenant": true, "user": true, "dev": true, + "stage": true, + "prod": true, + "test": true, + "static": true, + "assets": true, + "image": true, + "img": true, + "mail": true, + "smtp": true, + "pop": true, + "imap": true, + "ns": true, + "mx": true, + "webmaster": true, + "security": true, + "support": true, + "help": true, + "billing": true, + "account": true, + "config": true, + "status": true, + "health": true, + "metrics": true, + "grafana": true, + "prometheus": true, } )