From e6bfcf465f298c196832c7e02db1741dc3db4079 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 15:10:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?VITE=5FOIDC=5FCLIENT=5FID=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/lib/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devfront/src/lib/auth.ts b/devfront/src/lib/auth.ts index 6ce22a23..ac35f32d 100644 --- a/devfront/src/lib/auth.ts +++ b/devfront/src/lib/auth.ts @@ -3,7 +3,7 @@ import type { AuthProviderProps } from "react-oidc-context"; export const oidcConfig: AuthProviderProps = { authority: import.meta.env.VITE_OIDC_AUTHORITY || "http://localhost:5000/oidc", // Gateway Proxy URL - client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront-client", + client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront", redirect_uri: `${window.location.origin}/callback`, response_type: "code", scope: "openid offline_access profile email", // offline_access for refresh token From 5cb713a0095c2fc105cfc7f7d525fd283a6e0f28 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 15:10:36 +0900 Subject: [PATCH 2/4] =?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, } ) From b7b29bb50439dd7228bbe7f2e9bd89f50615f478 Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 15:21:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/utils/slug.go | 82 +++++++++++++++++----------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/backend/internal/utils/slug.go b/backend/internal/utils/slug.go index 79b57d28..b736a902 100644 --- a/backend/internal/utils/slug.go +++ b/backend/internal/utils/slug.go @@ -8,47 +8,47 @@ import ( var ( slugRegex = regexp.MustCompile(`^[a-z0-9-]+$`) reservedSlugs = map[string]bool{ - "admin": true, - "api": true, - "auth": true, - "system": true, - "root": true, - "super": true, - "public": true, - "internal": true, - "baron": true, - "sso": true, - "login": true, - "logout": true, - "signup": true, - "register": true, - "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, + "admin": true, + "api": true, + "auth": true, + "system": true, + "root": true, + "super": true, + "public": true, + "internal": true, + "baron": true, + "sso": true, + "login": true, + "logout": true, + "signup": true, + "register": true, + "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, } ) From c852af3168974e83eeef589ece634d3653612f8f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 19 Feb 2026 15:50:35 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EB=A6=B0=ED=8A=B8=20=EC=A0=81=EC=9A=A9=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/user_group_service_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 71cedb29..73686e9f 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -94,7 +94,7 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri return nil, nil } -func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error { +func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { return nil }