From c78604df06930232f9507dbff92b298277bfc733 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 6 Apr 2026 16:13:03 +0900 Subject: [PATCH 01/18] feat: implement dynamic tenant provisioning and remove hardcoded company mappings --- backend/internal/handler/auth_handler.go | 64 +++++-------------- .../handler/auth_handler_async_test.go | 9 ++- .../handler/auth_handler_signup_test.go | 25 ++++---- .../internal/handler/tenant_handler_test.go | 8 +++ backend/internal/handler/user_handler_test.go | 8 +++ .../internal/repository/tenant_repository.go | 9 +++ backend/internal/service/tenant_service.go | 49 ++++++++++++++ .../internal/service/tenant_service_test.go | 8 +++ .../service/user_group_service_test.go | 12 ++-- 9 files changed, 125 insertions(+), 67 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 53d105a1..27d1efa1 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -521,6 +521,14 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Warn("[Signup] Attempted to join non-active tenant by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.") } + } else { + // [New Policy] Try dynamic provisioning via Group Policies if tenant doesn't exist + tenant, err := h.TenantService.ProvisionTenantByDomain(c.Context(), domainName) + if err == nil && tenant != nil { + slog.Info("[Signup] Auto-provisioned tenant via group policy", "email", req.Email, "tenant", tenant.Slug) + companyCode = tenant.Slug + tenantID = &tenant.ID + } } } @@ -529,8 +537,6 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { 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 @@ -538,54 +544,18 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") } } else { - // If companyCode provided but not found, we automatically create one - // [New Policy] 자동 생성 로직 추가 - slog.Info("[Signup] CompanyCode not found, creating new tenant automatically", "slug", req.CompanyCode) - - // Determine name from CompanyCode - tenantName := req.CompanyCode - // Map slug to localized name if possible - slugToName := map[string]string{ - "HANMAC": "한맥", - "SAMAN": "삼안", - "JANGHEON": "장헌", - "HALLA": "한라", - "PTC": "PTC", - "BARON": "바론", - } - if name, ok := slugToName[strings.ToUpper(req.CompanyCode)]; ok { - tenantName = name - } - - // Create the tenant - // Note: creatorID is unknown at this point, will be set via Read-Model sync later - newTenant, err := h.TenantService.RegisterTenant(c.Context(), - tenantName, - req.CompanyCode, - domain.TenantTypeCompany, - "Automatically created during signup", - nil, // domains - nil, // parentID - "", // creatorID (will sync later) - ) - if err != nil { - // Handle race condition: if tenant was created by another request just now - if strings.Contains(err.Error(), "already exists") { - newTenant, err = h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) - } - - if err != nil || newTenant == nil { - slog.Error("[Signup] Failed to create tenant automatically", "slug", req.CompanyCode, "error", err) - return errorJSON(c, fiber.StatusInternalServerError, "Failed to initialize organization.") - } - } - - slog.Info("[Signup] Successfully created missing tenant", "slug", req.CompanyCode, "id", newTenant.ID) - tenantID = &newTenant.ID - companyCode = newTenant.Slug + // [New Policy] Do NOT create tenants automatically with hardcoded names. + // Only allow joining existing tenants. + slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email) + return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found. Please contact your administrator.") } } + if tenantID == nil { + slog.Warn("[Signup] No tenant assigned to user", "email", req.Email) + return errorJSON(c, fiber.StatusBadRequest, "We couldn't identify your organization. Please provide a company code or use your corporate email.") + } + // Normalize Phone (E.164 형태로 보관) normalizedPhone := strings.ReplaceAll(req.Phone, "-", "") normalizedPhone = strings.ReplaceAll(normalizedPhone, " ", "") diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index d3fee7f9..5b4344ab 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -198,7 +198,10 @@ func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName return false, nil } func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil } -func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} +func (m *AsyncMockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + return nil, nil +} +func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { return nil } @@ -269,7 +272,9 @@ func TestSignup_AsyncDB_Isolation(t *testing.T) { mockRedis.On("Delete", phoneKey).Return(nil) // Tenant Mocks - mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(nil, errors.New("not found")) + validTenant := &domain.Tenant{ID: "t1", Slug: "example", Status: domain.TenantStatusActive} + mockTenant.On("GetTenantByDomain", mock.Anything, "example.com").Return(validTenant, nil) + mockTenant.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil) // Kratos Mocks (Success) mockIdp.On("CreateUser", mock.Anything, "Password123!").Return("new-user-uuid", nil) diff --git a/backend/internal/handler/auth_handler_signup_test.go b/backend/internal/handler/auth_handler_signup_test.go index 23086a62..4dae6f72 100644 --- a/backend/internal/handler/auth_handler_signup_test.go +++ b/backend/internal/handler/auth_handler_signup_test.go @@ -5,6 +5,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -98,7 +99,7 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { }) mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil) - t.Run("Create Tenant if CompanyCode Missing", func(t *testing.T) { + t.Run("Fail - Tenant not found for CompanyCode", func(t *testing.T) { reqBody := domain.SignupRequest{ Email: "user@gmail.com", Password: "StrongPass123!", @@ -109,20 +110,15 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { } body, _ := json.Marshal(reqBody) - newTenant := &domain.Tenant{ID: "t_new", Slug: "new-slug", Status: domain.TenantStatusActive} - - mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil) - mockTenantSvc.On("RegisterTenant", mock.Anything, "new-slug", "new-slug", domain.TenantTypeCompany, mock.Anything, mock.Anything, mock.Anything, "").Return(newTenant, nil) - mockTenantSvc.On("GetTenant", mock.Anything, "t_new").Return(newTenant, nil) - mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) - mockRedis.On("Delete", mock.Anything).Return(nil) + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Once() + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "new-slug").Return(nil, nil).Once() req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req) - assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("Active Company Code", func(t *testing.T) { @@ -137,10 +133,11 @@ func TestSignup_CompanyCodeValidation(t *testing.T) { body, _ := json.Marshal(reqBody) validTenant := &domain.Tenant{ID: "t1", Slug: "valid-slug", Status: domain.TenantStatusActive} - mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil) - mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil) - mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil) - mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil) + mockTenantSvc.On("GetTenantByDomain", mock.Anything, "gmail.com").Return(nil, nil).Once() + mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "gmail.com").Return(nil, errors.New("not found")).Once() + mockTenantSvc.On("GetTenantBySlug", mock.Anything, "valid-slug").Return(validTenant, nil).Once() + mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once() + mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once() mockRedis.On("Delete", mock.Anything).Return(nil) req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body)) diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index b740f0d2..3239a6bf 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -88,6 +88,14 @@ func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } +func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + type MockUserRepoForHandler struct { mock.Mock } diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 0927a3c6..8ed8f550 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -103,6 +103,14 @@ func (m *MockTenantServiceForUser) ListManageableTenants(ctx context.Context, us return args.Get(0).([]domain.Tenant), args.Error(1) } +func (m *MockTenantServiceForUser) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + args := m.Called(ctx, domainName) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + // --- Tests --- func TestUserHandler_BulkCreateUsers(t *testing.T) { diff --git a/backend/internal/repository/tenant_repository.go b/backend/internal/repository/tenant_repository.go index 6eed4e73..0b8847d1 100644 --- a/backend/internal/repository/tenant_repository.go +++ b/backend/internal/repository/tenant_repository.go @@ -18,6 +18,7 @@ type TenantRepository interface { FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) + ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) } type tenantRepository struct { @@ -112,3 +113,11 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID return tenants, total, nil } + +func (r *tenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + var tenants []domain.Tenant + if err := r.db.WithContext(ctx).Where("type = ?", tenantType).Preload("Domains").Find(&tenants).Error; err != nil { + return nil, err + } + return tenants, nil +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index 067a798d..adaf55eb 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -22,6 +22,7 @@ type TenantService interface { ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) ApproveTenant(ctx context.Context, id string) error + ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) // 추가 SetKetoService(keto KetoService) // 추가 } @@ -311,3 +312,51 @@ func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) } return tenant != nil && tenant.Status == domain.TenantStatusActive, nil } + +func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { + // 1. Find all COMPANY_GROUP tenants + groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup) + if err != nil { + return nil, err + } + + for _, g := range groups { + // 2. Check autoProvisioning config + rawConfig, ok := g.Config["autoProvisioning"].(map[string]interface{}) + if !ok { + continue + } + + enabled, _ := rawConfig["enabled"].(bool) + if !enabled { + continue + } + + mapping, ok := rawConfig["mappingRules"].(map[string]interface{}) + if !ok { + continue + } + + // 3. Find rule for this domain + rule, ok := mapping[domainName].(map[string]interface{}) + if !ok { + continue + } + + slug, _ := rule["slug"].(string) + name, _ := rule["name"].(string) + + if slug == "" || name == "" { + continue + } + + // 4. Create new sub-tenant under this group + slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug) + + // Use RegisterTenant to handle DB creation and Keto Outbox sync + // creatorID is empty as per security policy (manual delegation later) + return s.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "Automatically provisioned via group policy", []string{domainName}, &g.ID, "") + } + + return nil, gorm.ErrRecordNotFound +} diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 37c68b9b..7213bee0 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -64,6 +64,14 @@ func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, pare return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2) } +func (m *MockTenantRepoForSvc) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + args := m.Called(ctx, tenantType) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Tenant), args.Error(1) +} + type MockKetoSvcForTenant struct { mock.Mock } diff --git a/backend/internal/service/user_group_service_test.go b/backend/internal/service/user_group_service_test.go index 4b98ba13..5a0a1763 100644 --- a/backend/internal/service/user_group_service_test.go +++ b/backend/internal/service/user_group_service_test.go @@ -158,14 +158,18 @@ func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName stri return nil, nil } -func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { - return nil -} - func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { return nil, 0, nil } +func (m *MockTenantRepository) ListByType(ctx context.Context, tenantType string) ([]domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error { + return nil +} + func TestUserGroupService_Create(t *testing.T) { mockRepo := new(MockUserGroupRepository) mockTenantRepo := new(MockTenantRepository) From 46db7ac02683a8b542e58447852afe1b1fc93c05 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 6 Apr 2026 16:29:08 +0900 Subject: [PATCH 02/18] fix: handle json parse exceptions on 404/500 signup responses gracefully --- userfront/lib/core/services/auth_proxy_service.dart | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 5b21ea20..e651b4ae 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -990,8 +990,17 @@ class AuthProxyService { ); if (response.statusCode != 200) { - final error = jsonDecode(response.body)['error'] ?? 'Signup failed'; - throw Exception(error); + String errorMessage = 'Signup failed'; + try { + final decoded = jsonDecode(response.body); + if (decoded is Map && decoded.containsKey('error')) { + errorMessage = decoded['error']; + } + } catch (e) { + // Fallback if the body isn't valid JSON (e.g., an HTML error page) + errorMessage = 'Server error (${response.statusCode}): ${response.body.isNotEmpty ? response.body.substring(0, response.body.length > 100 ? 100 : response.body.length) : "Unknown error"}'; + } + throw Exception(errorMessage); } } } From 332ac9c0d88245733d845ce66a7b5ae542b9cd12 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 6 Apr 2026 16:56:33 +0900 Subject: [PATCH 03/18] feat: dynamic frontend tenant dropdown --- backend/cmd/server/main.go | 1 + backend/internal/handler/auth_handler.go | 39 +++++++++ .../lib/core/services/auth_proxy_service.dart | 11 +++ .../auth/presentation/signup_screen.dart | 83 +++++++------------ 4 files changed, 83 insertions(+), 51 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0392a1d8..66677ee4 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -567,6 +567,7 @@ func main() { // Signup Routes signup := auth.Group("/signup") + signup.Get("/tenants", authHandler.GetActiveTenants) signup.Post("/check-email", authHandler.CheckEmail) signup.Post("/check-login-id", authHandler.CheckLoginID) signup.Post("/send-email-code", authHandler.SendSignupEmailCode) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 27d1efa1..22fdab4a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -463,6 +463,45 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { } // Signup - Finalize registration +func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { + if h.TenantService == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable") + } + + // List all tenants (we use a large limit for now to get all affiliates) + tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") + } + + type tenantResp struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Type string `json:"type"` + Domains []string `json:"domains"` + } + + var results []tenantResp + for _, t := range tenants { + if t.Status == domain.TenantStatusActive && (t.Type == domain.TenantTypeCompany || t.Type == domain.TenantTypeCompanyGroup) { + var domains []string + for _, d := range t.Domains { + domains = append(domains, d.Domain) + } + results = append(results, tenantResp{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Type: t.Type, + Domains: domains, + }) + } + } + + return c.JSON(results) +} + func (h *AuthHandler) Signup(c *fiber.Ctx) error { var req domain.SignupRequest if err := c.BodyParser(&req); err != nil { diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index e651b4ae..5867402d 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -923,6 +923,17 @@ class AuthProxyService { } } + static Future>> getActiveTenants() async { + final url = Uri.parse('$_baseUrl/api/v1/auth/signup/tenants'); + final response = await http.get(url); + + if (response.statusCode == 200) { + final List data = jsonDecode(response.body); + return data.cast>(); + } + return []; + } + static Future sendSignupCode(String target, String type) async { final path = type == 'email' ? 'send-email-code' : 'send-sms-code'; final url = Uri.parse('$_baseUrl/api/v1/auth/signup/$path'); diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index bde6317f..047799f1 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -54,6 +54,10 @@ class _SignupScreenState extends State { bool _isPasswordObscured = true; bool _isConfirmPasswordObscured = true; + // Dynamic Tenants + List> _tenants = []; + final Map _affiliateDomains = {}; + // Inline Errors String? _emailError; String? _phoneError; @@ -66,20 +70,32 @@ class _SignupScreenState extends State { Timer? _phoneTimer; int _phoneSeconds = 0; - // 가족사 도메인 맵 - final Map _affiliateDomains = { - 'hanmaceng.co.kr': 'HANMAC', - 'samaneng.com': 'SAMAN', - 'jangheon.co.kr': 'JANGHEON', - 'hallasanup.com': 'HALLA', - 'pre-cast.co.kr': 'PTC', - 'baroncs.co.kr': 'BARON', - }; - @override void initState() { super.initState(); _loadPolicy(); + _fetchTenants(); + } + + Future _fetchTenants() async { + try { + final tenants = await AuthProxyService.getActiveTenants(); + if (mounted) { + setState(() { + _tenants = tenants; + _affiliateDomains.clear(); + for (var t in tenants) { + if (t['domains'] != null) { + for (var d in (t['domains'] as List)) { + _affiliateDomains[d.toString().toLowerCase()] = t['slug']; + } + } + } + }); + } + } catch (e) { + debugPrint('Failed to load tenants: $e'); + } } Future _loadPolicy() async { @@ -1505,47 +1521,12 @@ class _SignupScreenState extends State { ), border: const OutlineInputBorder(), ), - items: [ - DropdownMenuItem( - value: 'HANMAC', - child: Text( - tr('domain.company.hanmac'), - ), - ), - DropdownMenuItem( - value: 'SAMAN', - child: Text( - tr('domain.company.saman'), - ), - ), - DropdownMenuItem( - value: 'PTC', - child: Text( - tr( - 'domain.company.ptc', - fallback: 'PTC', - ), - ), - ), - DropdownMenuItem( - value: 'JANGHEON', - child: Text( - tr('domain.company.jangheon'), - ), - ), - DropdownMenuItem( - value: 'BARON', - child: Text( - tr('domain.company.baron'), - ), - ), - DropdownMenuItem( - value: 'HALLA', - child: Text( - tr('domain.company.halla'), - ), - ), - ], + items: _tenants.map((t) { + return DropdownMenuItem( + value: t['slug'], + child: Text(t['name'] ?? t['slug']), + ); + }).toList(), onChanged: _isAffiliateEmail ? null : (val) => setState( From 43ec19e94f8245eb78e21c33139dff2d1792f296 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 6 Apr 2026 17:20:02 +0900 Subject: [PATCH 04/18] feat: remove auto-selection of affiliate by email domain and clean up UI --- .../auth/presentation/signup_screen.dart | 180 ++++++------------ 1 file changed, 55 insertions(+), 125 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 047799f1..6908458e 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -45,7 +45,6 @@ class _SignupScreenState extends State { bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; String? _companyCode; - bool _isAffiliateEmail = false; // 가족사 이메일 여부 bool _termsAccepted = false; bool _privacyAccepted = false; bool _isLoading = false; @@ -56,7 +55,6 @@ class _SignupScreenState extends State { // Dynamic Tenants List> _tenants = []; - final Map _affiliateDomains = {}; // Inline Errors String? _emailError; @@ -83,14 +81,6 @@ class _SignupScreenState extends State { if (mounted) { setState(() { _tenants = tenants; - _affiliateDomains.clear(); - for (var t in tenants) { - if (t['domains'] != null) { - for (var d in (t['domains'] as List)) { - _affiliateDomains[d.toString().toLowerCase()] = t['slug']; - } - } - } }); } } catch (e) { @@ -125,35 +115,11 @@ class _SignupScreenState extends State { super.dispose(); } - // 이메일 입력 시 도메인 체크 로직 + // 이메일 입력 시 도메인 체크 로직 (자동 선택 제거) void _checkEmailAffiliation(String email) { - if (!email.contains('@')) { - if (_isAffiliateEmail) { - setState(() { - _isAffiliateEmail = false; - _affiliationType = 'GENERAL'; - _companyCode = null; - }); - } - return; - } - - final domain = email.split('@').last.toLowerCase(); - if (_affiliateDomains.containsKey(domain)) { - setState(() { - _isAffiliateEmail = true; - _affiliationType = 'AFFILIATE'; - _companyCode = _affiliateDomains[domain]; - }); - } else { - if (_isAffiliateEmail) { - setState(() { - _isAffiliateEmail = false; - _affiliationType = 'GENERAL'; - _companyCode = null; - }); - } - } + // Note: We no longer auto-set _companyCode or _affiliationType based on domain + // as per user requirement (same domain can belong to different affiliates). + return; } void _startTimer(String type) { @@ -1450,55 +1416,46 @@ class _SignupScreenState extends State { const SizedBox(height: 18), _buildProfileFieldGroup( title: tr('ui.userfront.signup.profile.affiliation_type'), - description: _isAffiliateEmail - ? tr('msg.userfront.signup.profile.affiliate_hint') - : '소속 유형과 회사 정보를 입력합니다.', + description: '소속 유형과 회사 정보를 입력합니다.', isDesktop: isDesktop, - trailing: _isAffiliateEmail - ? _buildAutoDetectedBadge() - : null, + trailing: null, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.affiliation_type', - ), - border: const OutlineInputBorder(), - ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text( - tr('domain.affiliation.general'), - ), - ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text( - tr('domain.affiliation.affiliate'), - ), - ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - }); - }, + DropdownButtonFormField( + key: ValueKey(_affiliationType), + initialValue: _affiliationType, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', ), + border: const OutlineInputBorder(), ), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text( + tr('domain.affiliation.general'), + ), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text( + tr('domain.affiliation.affiliate'), + ), + ), + ], + onChanged: (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + if (_affiliationType == 'GENERAL') { + _companyCode = null; + } + }); + }, ), AnimatedSize( duration: const Duration(milliseconds: 180), @@ -1508,31 +1465,23 @@ class _SignupScreenState extends State { children: [ if (_affiliationType == 'AFFILIATE') ...[ const SizedBox(height: 14), - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr( - 'ui.userfront.signup.profile.company', - ), - border: const OutlineInputBorder(), - ), - items: _tenants.map((t) { - return DropdownMenuItem( - value: t['slug'], - child: Text(t['name'] ?? t['slug']), - ); - }).toList(), - onChanged: _isAffiliateEmail - ? null - : (val) => setState( - () => _companyCode = val, - ), + DropdownButtonFormField( + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', ), + border: const OutlineInputBorder(), + ), + items: _tenants.map((t) { + return DropdownMenuItem( + value: t['slug'], + child: Text(t['name'] ?? t['slug']), + ); + }).toList(), + onChanged: (val) => setState( + () => _companyCode = val, ), ), ], @@ -1577,9 +1526,7 @@ class _SignupScreenState extends State { } Widget _buildProfileInfoNoticeCard({required bool isDesktop}) { - final description = _isAffiliateEmail - ? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.' - : '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; + const description = '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; return DecoratedBox( decoration: BoxDecoration( @@ -1679,23 +1626,6 @@ class _SignupScreenState extends State { ); } - Widget _buildAutoDetectedBadge() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: const Color(0xFFEEF2FF), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFC7D2FE)), - ), - child: const Text( - '자동 선택', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: Color(0xFF4338CA), - ), - ), - ); } String _buildPolicyDescription() { From 97a60ead91686276d5d1eabe2f6b081b108e7095 Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 6 Apr 2026 17:37:34 +0900 Subject: [PATCH 05/18] fix: resolve syntax error in signup_screen caused by redundant closing brace --- userfront/lib/features/auth/presentation/signup_screen.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index 6908458e..cbd0871a 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1626,8 +1626,6 @@ class _SignupScreenState extends State { ); } - } - String _buildPolicyDescription() { if (_isPolicyLoading) { return tr('msg.userfront.signup.policy.loading'); From 4e7f3e7235ccc1f282e2c0b11545107fdece982b Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 7 Apr 2026 11:14:45 +0900 Subject: [PATCH 06/18] feat(auth): enforce explicit tenant selection and dynamic filtering (#500) - Refactor `GetActiveTenants` to filter dynamically based on the email domain, removing hardcoded affiliate slugs. - Update `Signup` to require an explicit `CompanyCode` choice for internal domains, removing automatic provisioning and implicit tenant assignment. - Add markdown diagram detailing the revised, secure B2B2B dynamic provisioning and inheritance flow. --- backend/internal/handler/auth_handler.go | 153 ++++++++++++++++------- docs/b2b2b_dynamic_provisioning_flow.md | 59 +++++++++ 2 files changed, 168 insertions(+), 44 deletions(-) create mode 100644 docs/b2b2b_dynamic_provisioning_flow.md diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 22fdab4a..0911615a 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -459,16 +459,48 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { state.Verified = true h.saveSignupState(key, state, signupStateExpiration) - return c.JSON(fiber.Map{"success": true}) + // [New] Check if this is an affiliate domain to let frontend lock the choice + isAffiliate := false + parts := strings.Split(req.Target, "@") + if req.Type == "email" && len(parts) == 2 { + isAffiliate, _ = h.TenantService.IsDomainAllowed(c.Context(), parts[1]) + } + + return c.JSON(fiber.Map{ + "success": true, + "isAffiliate": isAffiliate, + }) } // Signup - Finalize registration +// GetActiveTenants - List active tenants ONLY if the email is verified in Redis func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { if h.TenantService == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "Tenant service unavailable") } - // List all tenants (we use a large limit for now to get all affiliates) + email := c.Query("email") + if email == "" { + // No email provided, return empty list (Security policy) + return c.JSON([]interface{}{}) + } + + // 1. Verify Verification Status in Redis + emailKey := prefixSignupEmail + email + state, _ := h.getSignupState(emailKey) + if state == nil || !state.Verified { + slog.Warn("[GetActiveTenants] Unverified access attempt", "email", email) + return errorJSON(c, fiber.StatusForbidden, "Email verification is required before selecting an organization.") + } + + // 2. Extract domain from verified email + parts := strings.Split(email, "@") + if len(parts) != 2 { + return c.JSON([]interface{}{}) + } + domainFilter := parts[1] + + // 3. List and Filter Tenants tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") if err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch tenants") @@ -484,19 +516,33 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { var results []tenantResp for _, t := range tenants { - if t.Status == domain.TenantStatusActive && (t.Type == domain.TenantTypeCompany || t.Type == domain.TenantTypeCompanyGroup) { - var domains []string - for _, d := range t.Domains { - domains = append(domains, d.Domain) - } - results = append(results, tenantResp{ - ID: t.ID, - Name: t.Name, - Slug: t.Slug, - Type: t.Type, - Domains: domains, - }) + if t.Status != domain.TenantStatusActive { + continue } + + // Strictly filter by the domain of the verified email + match := false + for _, d := range t.Domains { + if strings.EqualFold(d.Domain, domainFilter) { + match = true + break + } + } + if !match { + continue + } + + var domains []string + for _, d := range t.Domains { + domains = append(domains, d.Domain) + } + results = append(results, tenantResp{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Type: t.Type, + Domains: domains, + }) } return c.JSON(results) @@ -543,39 +589,52 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "Identity provider unavailable") } - // [Strict] Enforce Tenant Auto-Assignment + // [New Policy] Enforce Explicit Tenant Assignment (No Auto-Provisioning) companyCode := "" var tenantID *string parts := strings.Split(req.Email, "@") - if len(parts) == 2 { - domainName := parts[1] - tenant, err := h.TenantService.GetTenantByDomain(c.Context(), domainName) - if err == nil && tenant != nil { - if tenant.Status == domain.TenantStatusActive { - 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 by domain", "email", req.Email, "tenant", tenant.Slug, "status", tenant.Status) - return errorJSON(c, fiber.StatusForbidden, "Your organization's tenant is currently not active.") - } - } else { - // [New Policy] Try dynamic provisioning via Group Policies if tenant doesn't exist - tenant, err := h.TenantService.ProvisionTenantByDomain(c.Context(), domainName) - if err == nil && tenant != nil { - slog.Info("[Signup] Auto-provisioned tenant via group policy", "email", req.Email, "tenant", tenant.Slug) - companyCode = tenant.Slug - tenantID = &tenant.ID - } - } + if len(parts) != 2 { + return errorJSON(c, fiber.StatusBadRequest, "Invalid email format") + } + domainName := parts[1] + + // Check if this domain is registered to ANY company + isInternal, _ := h.TenantService.IsDomainAllowed(c.Context(), domainName) + + // [Strict Policy] Force AffiliationType based on domain (User cannot choose) + if isInternal { + req.AffiliationType = "AFFILIATE" + slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email) + } else { + req.AffiliationType = "GENERAL" + slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email) } - // Fallback/Validation for manually provided CompanyCode if domain lookup didn't yield a tenant - if tenantID == nil && req.CompanyCode != "" { + // If user provided a CompanyCode, verify it exists and is active + if req.CompanyCode != "" { + // [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode + if !isInternal { + slog.Warn("[Signup] Security violation: non-internal email providing CompanyCode", "email", req.Email) + return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.") + } + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) if err == nil && tenant != nil { if tenant.Status == domain.TenantStatusActive { + // [Security] Final domain cross-check for the selected tenant + match := false + for _, d := range tenant.Domains { + if strings.EqualFold(d.Domain, domainName) { + match = true + break + } + } + if !match { + slog.Warn("[Signup] Domain mismatch for selected organization", "email", req.Email, "targetTenant", tenant.Slug) + return errorJSON(c, fiber.StatusForbidden, "Your email domain does not match the selected organization.") + } + slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug tenantID = &tenant.ID @@ -583,16 +642,22 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.") } } else { - // [New Policy] Do NOT create tenants automatically with hardcoded names. - // Only allow joining existing tenants. slog.Warn("[Signup] Attempted to join non-existent organization", "slug", req.CompanyCode, "email", req.Email) - return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found. Please contact your administrator.") + return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.") } + } else { + // If no CompanyCode provided but it's an internal domain, they MUST select one + if isInternal && req.AffiliationType == "AFFILIATE" { + return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.") + } + + // If they chose GENERAL even with internal email, or it's a completely external domain + // Here we might assign a "GENERAL" pool or a personal tenant. } - if tenantID == nil { - slog.Warn("[Signup] No tenant assigned to user", "email", req.Email) - return errorJSON(c, fiber.StatusBadRequest, "We couldn't identify your organization. Please provide a company code or use your corporate email.") + // [Strict] If no tenantID but trying to be an affiliate, it's an error + if tenantID == nil && req.AffiliationType == "AFFILIATE" { + return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") } // Normalize Phone (E.164 형태로 보관) diff --git a/docs/b2b2b_dynamic_provisioning_flow.md b/docs/b2b2b_dynamic_provisioning_flow.md new file mode 100644 index 00000000..4f6f7fa7 --- /dev/null +++ b/docs/b2b2b_dynamic_provisioning_flow.md @@ -0,0 +1,59 @@ +# 가족사 테넌트 가입 및 관리 정책 (인증 기반 수정안) + +이 문서는 보안 강화를 위해 **이메일 인증 성공 시에만 가족사 소속을 선택**할 수 있도록 변경된 가입 흐름을 설명합니다. + +## 회원가입 및 권한 관리 흐름도 + +```mermaid +graph TD + %% 시작점 + A([사용자 회원가입 시작]) --> B[이메일 입력 및 인증 코드 발송] + B --> C{이메일 인증 성공?} + + C -- No --> B + C -- Yes --> D{인증된 이메일이
내부/가족사 도메인인가?} + + %% 일반 도메인 (gmail, naver 등) + D -- No
(External) --> E[개인 테넌트 자동 할당
Type: PERSONAL] + E --> J + + %% 내부 도메인 (hanmaceng.co.kr 등) + D -- Yes
(Internal) --> F[가족사 목록 노출 및 선택
Select Company Code] + F --> G{선택한 코드가
ACTIVE 상태인가?} + + G -- No --> F + G -- Yes --> J[Ory Kratos 계정 생성] + + %% 유저 생성 및 권한 할당 + J --> K[(Local DB 유저 레코드 생성)] + K --> N[기본 권한 할당: user
Keto: members 부여] + + N --> O([회원가입 완료]) + + %% 관리자 수동 개입 (별도 흐름) + P((최고 관리자
Super Admin)) -.-> Q[사용자 역할 변경
user -> tenant_admin] + Q -.-> R[(Keto 권한 수동 할당
owners, admins 부여)] + + %% 스타일링 + classDef process fill:#e1f5fe,stroke:#01579b,stroke-width:2px; + classDef decision fill:#fff3e0,stroke:#e65100,stroke-width:2px; + classDef db fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px; + classDef startend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px; + classDef admin fill:#f5f5f5,stroke:#616161,stroke-width:2px,stroke-dasharray: 5 5; + + class A,O startend; + class B,F,J,N,Q process; + class C,D,G decision; + class E,K,R db; + class P admin; +``` + +## 핵심 정책 변경 사항 + +1. **선(先)인증 후(後)선택:** 사용자는 이메일 소유권 인증(OTP 또는 인증 링크)을 완료하기 전까지는 어떠한 가족사 소속도 선택할 수 없습니다. +2. **도메인 기반 노출 제어:** + - 인증된 이메일 도메인이 시스템에 등록된 가족사 도메인(`hanmaceng.co.kr` 등)일 경우에만 소속 선택 UI가 활성화됩니다. + - 일반 외부 도메인(gmail, naver 등)은 `PERSONAL` 테넌트로 강제 배정되어 가족사 리스트 자체가 노출되지 않습니다. +3. **이메일 도메인 중복 방지:** 같은 도메인을 쓰더라도 다른 소속일 수 있는 경우(예: 협력사 등)를 대비하여, 인증 성공 후에도 사용자가 직접 본인의 정확한 소속(`Company Code`)을 선택하게 하여 데이터 무결성을 확보합니다. +4. **수동 권한 위임 유지:** 모든 가입자는 기본적으로 `user` 권한을 부여받으며, 테넌트 관리자(`tenant_admin`)나 오너(`owner`) 권한은 지주사 관리자가 사용자의 신원을 최종 확인한 후 수동으로 부여합니다. +5. **실시간 상태 검증:** 가입 시점에 선택한 테넌트가 `ACTIVE` 상태가 아닐 경우 가입 진행을 차단합니다. From b3a7f47cf7c5958988bb01f6c8f126f8e537ab6a Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 7 Apr 2026 13:01:30 +0900 Subject: [PATCH 07/18] feat(auth): lock affiliation type on frontend based on verified email domain (#500) --- backend/internal/handler/auth_handler.go | 81 ++++++++++--------- .../lib/core/services/auth_proxy_service.dart | 23 +++--- .../auth/presentation/signup_screen.dart | 59 ++++++++++---- 3 files changed, 99 insertions(+), 64 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0911615a..887ec8ec 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -406,6 +406,24 @@ func (h *AuthHandler) SendSignupSmsCode(c *fiber.Ctx) error { return c.JSON(fiber.Map{"message": "Verification code sent"}) } +var affiliateSlugs = map[string]bool{ + "hanmac": true, + "saman": true, + "ptc": true, + "jangheon": true, + "baron": true, + "halla": true, +} + +func (h *AuthHandler) isAffiliateTenant(ctx context.Context, domainName string) (bool, *domain.Tenant) { + tenant, err := h.TenantService.GetTenantByDomain(ctx, domainName) + if err != nil || tenant == nil { + return false, nil + } + // [Strict] Check if the slug belongs to the predefined family company slugs + return affiliateSlugs[strings.ToLower(tenant.Slug)], tenant +} + // VerifySignupCode - Verifies the code for email or phone func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { var req domain.VerifySignupCodeRequest @@ -459,11 +477,11 @@ func (h *AuthHandler) VerifySignupCode(c *fiber.Ctx) error { state.Verified = true h.saveSignupState(key, state, signupStateExpiration) - // [New] Check if this is an affiliate domain to let frontend lock the choice + // [New] Check if this is a family affiliate domain to let frontend lock the choice isAffiliate := false parts := strings.Split(req.Target, "@") if req.Type == "email" && len(parts) == 2 { - isAffiliate, _ = h.TenantService.IsDomainAllowed(c.Context(), parts[1]) + isAffiliate, _ = h.isAffiliateTenant(c.Context(), parts[1]) } return c.JSON(fiber.Map{ @@ -498,7 +516,14 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { if len(parts) != 2 { return c.JSON([]interface{}{}) } - domainFilter := parts[1] + domainName := parts[1] + + // [Policy] Verify if the email belongs to any family affiliate domain + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) + if !isInternal { + // If not an affiliate email, do not show any tenants + return c.JSON([]interface{}{}) + } // 3. List and Filter Tenants tenants, _, err := h.TenantService.ListTenants(c.Context(), 1000, 0, "") @@ -516,19 +541,8 @@ func (h *AuthHandler) GetActiveTenants(c *fiber.Ctx) error { var results []tenantResp for _, t := range tenants { - if t.Status != domain.TenantStatusActive { - continue - } - - // Strictly filter by the domain of the verified email - match := false - for _, d := range t.Domains { - if strings.EqualFold(d.Domain, domainFilter) { - match = true - break - } - } - if !match { + // [Strict] Only allow choosing defined family company slugs + if t.Status != domain.TenantStatusActive || !affiliateSlugs[strings.ToLower(t.Slug)] { continue } @@ -599,10 +613,10 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { } domainName := parts[1] - // Check if this domain is registered to ANY company - isInternal, _ := h.TenantService.IsDomainAllowed(c.Context(), domainName) + // Check if this domain belongs to a predefined family affiliate + isInternal, _ := h.isAffiliateTenant(c.Context(), domainName) - // [Strict Policy] Force AffiliationType based on domain (User cannot choose) + // [Strict Policy] Force AffiliationType based on predefined family slugs (User cannot choose) if isInternal { req.AffiliationType = "AFFILIATE" slog.Info("[Signup] Forcing AffiliationType to AFFILIATE", "email", req.Email) @@ -611,7 +625,7 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email) } - // If user provided a CompanyCode, verify it exists and is active + // If user provided a CompanyCode, verify it exists and is a family affiliate if req.CompanyCode != "" { // [Security] Cross-check: If domain is NOT internal, they cannot provide a CompanyCode if !isInternal { @@ -619,21 +633,16 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.") } + // Verify the selected company code exists and is indeed a family company + if !affiliateSlugs[strings.ToLower(req.CompanyCode)] { + return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.") + } + tenant, err := h.TenantService.GetTenantBySlug(c.Context(), req.CompanyCode) if err == nil && tenant != nil { if tenant.Status == domain.TenantStatusActive { - // [Security] Final domain cross-check for the selected tenant - match := false - for _, d := range tenant.Domains { - if strings.EqualFold(d.Domain, domainName) { - match = true - break - } - } - if !match { - slog.Warn("[Signup] Domain mismatch for selected organization", "email", req.Email, "targetTenant", tenant.Slug) - return errorJSON(c, fiber.StatusForbidden, "Your email domain does not match the selected organization.") - } + // We no longer strictly cross-check if the chosen tenant owns the email domain. + // Being an 'isInternal' (family) email is enough to join ANY family affiliate. slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug) companyCode = tenant.Slug @@ -646,16 +655,12 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.") } } else { - // If no CompanyCode provided but it's an internal domain, they MUST select one - if isInternal && req.AffiliationType == "AFFILIATE" { + // If it's a family affiliate domain, they MUST select one of the family companies + if isInternal { return errorJSON(c, fiber.StatusBadRequest, "Please select your organization.") } - - // If they chose GENERAL even with internal email, or it's a completely external domain - // Here we might assign a "GENERAL" pool or a personal tenant. } - // [Strict] If no tenantID but trying to be an affiliate, it's an error if tenantID == nil && req.AffiliationType == "AFFILIATE" { return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.") } diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 5867402d..790ce60e 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -923,13 +923,19 @@ class AuthProxyService { } } - static Future>> getActiveTenants() async { - final url = Uri.parse('$_baseUrl/api/v1/auth/signup/tenants'); - final response = await http.get(url); + static Future>> getActiveTenants({ + String? email, + }) async { + var uriString = '$_baseUrl/api/v1/auth/signup/tenants'; + if (email != null && email.isNotEmpty) { + uriString += '?email=${Uri.encodeComponent(email)}'; + } + final url = Uri.parse(uriString); + final response = await http.get(url); if (response.statusCode == 200) { - final List data = jsonDecode(response.body); - return data.cast>(); + final List list = jsonDecode(response.body); + return list.cast>(); } return []; } @@ -953,7 +959,7 @@ class AuthProxyService { } } - static Future verifySignupCode( + static Future> verifySignupCode( String target, String type, String code, @@ -967,10 +973,9 @@ class AuthProxyService { ); if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return data['success'] ?? false; + return jsonDecode(response.body); } - return false; + throw Exception('Verification failed'); } static Future signup({ diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index cbd0871a..20faea8b 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -44,6 +44,7 @@ class _SignupScreenState extends State { bool _isEmailVerified = false; bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; + bool _isAffiliateLocked = false; String? _companyCode; bool _termsAccepted = false; bool _privacyAccepted = false; @@ -72,15 +73,23 @@ class _SignupScreenState extends State { void initState() { super.initState(); _loadPolicy(); - _fetchTenants(); + // initState에서는 _fetchTenants() 호출 제외 } Future _fetchTenants() async { + if (!_isEmailVerified) return; + try { - final tenants = await AuthProxyService.getActiveTenants(); + final tenants = await AuthProxyService.getActiveTenants( + email: _emailController.text.trim(), + ); if (mounted) { setState(() { _tenants = tenants; + if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') { + // 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능 + _companyCode ??= _tenants.first['slug']; + } }); } } catch (e) { @@ -195,18 +204,32 @@ class _SignupScreenState extends State { final code = _emailCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _emailController.text.trim(), 'email', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isEmailVerified = true; _emailTimer?.cancel(); _emailSeconds = 0; _emailError = null; + + if (res['isAffiliate'] == true) { + _affiliationType = 'AFFILIATE'; + _isAffiliateLocked = true; + } else { + _affiliationType = 'GENERAL'; + _companyCode = null; + _isAffiliateLocked = true; + } }); + + // Only fetch tenants if it's an affiliate domain + if (res['isAffiliate'] == true) { + _fetchTenants(); + } } else { setState( () => _emailError = tr('msg.userfront.signup.email.code_mismatch'), @@ -248,12 +271,12 @@ class _SignupScreenState extends State { final code = _phoneCodeController.text.trim(); if (code.length != 6) return; try { - final success = await AuthProxyService.verifySignupCode( + final res = await AuthProxyService.verifySignupCode( _phoneController.text.trim(), 'phone', code, ); - if (success) { + if (res['success'] == true) { setState(() { _isPhoneVerified = true; _phoneTimer?.cancel(); @@ -1445,17 +1468,19 @@ class _SignupScreenState extends State { ), ), ], - onChanged: (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - if (_affiliationType == 'GENERAL') { - _companyCode = null; - } - }); - }, + onChanged: _isAffiliateLocked + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + if (_affiliationType == 'GENERAL') { + _companyCode = null; + } + }); + }, ), AnimatedSize( duration: const Duration(milliseconds: 180), From 02255116f4ccbadd39556113b43f8d2f7bfb64e6 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 7 Apr 2026 15:34:25 +0900 Subject: [PATCH 08/18] feat(org): enhance bulk import to support multi-level hierarchy, auto-provision users, and map matrix organizations (#500) --- .../internal/service/kratos_admin_service.go | 63 +++++++ backend/internal/service/org_chart_service.go | 166 +++++++++++------- docs/organization-chart-policy.md | 157 ++++++++++------- 3 files changed, 260 insertions(+), 126 deletions(-) diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index 35141017..dce9813a 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -1,6 +1,7 @@ package service import ( + "baron-sso-backend/internal/domain" "bytes" "context" "encoding/json" @@ -34,6 +35,7 @@ type KratosAdminService interface { UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error DeleteIdentity(ctx context.Context, identityID string) error + CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) } type kratosAdminService struct { @@ -239,6 +241,67 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit return nil } +func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) { + if user == nil { + return "", fmt.Errorf("kratos admin: user payload is nil") + } + + traits := map[string]interface{}{ + "email": user.Email, + "name": user.Name, + } + if user.PhoneNumber != "" { + traits["phone_number"] = user.PhoneNumber + } + for k, v := range user.Attributes { + if k == "id" || k == "email" { + continue + } + traits[k] = v + } + + payload := map[string]interface{}{ + "schema_id": "default", + "traits": traits, + "credentials": map[string]interface{}{ + "password": map[string]interface{}{ + "config": map[string]string{ + "password": password, + }, + }, + }, + "state": "active", + } + + body, _ := json.Marshal(payload) + endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody)) + } + + var created struct { + ID string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return "", err + } + + return created.ID, nil +} + func hashPasswordForKratosAdmin(password string) (string, error) { hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { diff --git a/backend/internal/service/org_chart_service.go b/backend/internal/service/org_chart_service.go index d8e419cf..560f0055 100644 --- a/backend/internal/service/org_chart_service.go +++ b/backend/internal/service/org_chart_service.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "strings" + "time" "github.com/google/uuid" ) @@ -48,22 +49,43 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R return fmt.Errorf("failed to read CSV header: %w", err) } - // Map header columns + // Map header columns (Support both English and Korean) colMap := make(map[string]int) for i, name := range header { - colMap[strings.ToLower(strings.TrimSpace(name))] = i + cleanName := strings.ToLower(strings.TrimSpace(name)) + colMap[cleanName] = i } - // Required columns - required := []string{"email", "name", "organization", "position", "jobtitle"} - for _, req := range required { - if _, ok := colMap[req]; !ok { - return fmt.Errorf("missing required column: %s", req) + // Dynamic column detection for hierarchy + hierarchyCols := []string{"그룹", "디비젼", "팀", "셀"} + hierarchyIdx := make([]int, 0) + for _, col := range hierarchyCols { + if idx, ok := colMap[col]; ok { + hierarchyIdx = append(hierarchyIdx, idx) } } - // Cache for created/found organization units to handle hierarchy efficiently - // key: path (e.g. "HQ/Sales"), value: ID + // Map English keys for core fields + fieldMapping := map[string][]string{ + "email": {"email", "이메일"}, + "name": {"name", "이름"}, + "position": {"position", "직급"}, + "jobtitle": {"jobtitle", "직무"}, + "company": {"company", "소속"}, + "is_owner": {"is_owner", "구분"}, + } + + actualMap := make(map[string]int) + for key, aliases := range fieldMapping { + for _, alias := range aliases { + if idx, ok := colMap[alias]; ok { + actualMap[key] = idx + break + } + } + } + + // Path cache for hierarchy pathCache := make(map[string]string) for { @@ -76,61 +98,98 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R continue } - email := strings.TrimSpace(record[colMap["email"]]) - name := strings.TrimSpace(record[colMap["name"]]) - orgPath := strings.TrimSpace(record[colMap["organization"]]) - position := strings.TrimSpace(record[colMap["position"]]) - jobTitle := strings.TrimSpace(record[colMap["jobtitle"]]) + email := strings.TrimSpace(record[actualMap["email"]]) + name := strings.TrimSpace(record[actualMap["name"]]) + position := strings.TrimSpace(record[actualMap["position"]]) + jobTitle := strings.TrimSpace(record[actualMap["jobtitle"]]) + companyName := strings.TrimSpace(record[actualMap["company"]]) + + // Determine if owner (e.g. "팀장", "그룹장", "센터장", "실장") isOwner := false - if idx, ok := colMap["is_owner"]; ok && idx < len(record) { - val := strings.ToLower(record[idx]) - isOwner = val == "true" || val == "y" || val == "1" || val == "yes" + if idx, ok := actualMap["is_owner"]; ok { + val := record[idx] + isOwner = strings.HasSuffix(val, "장") || strings.EqualFold(val, "true") || val == "1" } - if email == "" || name == "" || orgPath == "" { + if email == "" || name == "" { continue } - // 1. Process Organization Hierarchy - leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache) - if err != nil { - slog.Error("Failed to ensure org path", "path", orgPath, "error", err) - continue + // 1. Process Hierarchy (Build path from multiple columns) + var parts []string + for _, idx := range hierarchyIdx { + val := strings.TrimSpace(record[idx]) + if val != "" && val != "-" { + parts = append(parts, val) + } } + orgPath := strings.Join(parts, "/") - // 2. Upsert User - // Check if user exists in Kratos first (SoT) - kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) - if err != nil || kratosID == "" { - slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email) - continue - } - - // Update User in Local DB (Read-Model) - user, err := s.userRepo.FindByID(ctx, kratosID) - if err != nil { - // If not in local DB, create it - user = &domain.User{ - ID: kratosID, - Email: email, + leafID := tenantID // Default to root + if orgPath != "" { + leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache) + if err != nil { + slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err) + continue } } - user.Name = name - user.Position = position - user.JobTitle = jobTitle - user.Department = orgPath - user.TenantID = &tenantID - user.Status = "active" + // 2. Ensure User exists in Kratos (Auto-create if missing) + kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email) + if err != nil || kratosID == "" { + slog.Info("User not found in Kratos, auto-creating...", "email", email) + + // Map company name to slug (Simple mapping for now) + companyCode := strings.ToLower(companyName) + if companyCode == "한맥" { companyCode = "hanmac" } + if companyCode == "삼안" { companyCode = "saman" } + + brokerUser := &domain.BrokerUser{ + Email: email, + Name: name, + Attributes: map[string]interface{}{ + "affiliationType": "AFFILIATE", + "companyCode": companyCode, + "department": orgPath, + "grade": "member", + }, + } + // Default password for bulk import + newID, createErr := s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#") + if createErr != nil { + slog.Error("Failed to auto-create user in Kratos", "email", email, "error", createErr) + continue + } + kratosID = newID + } + + // 3. Update User in Local DB + companyCode := strings.ToLower(companyName) + if companyCode == "한맥" { companyCode = "hanmac" } + if companyCode == "삼안" { companyCode = "saman" } + + user := &domain.User{ + ID: kratosID, + Email: email, + Name: name, + Position: position, + JobTitle: jobTitle, + Department: orgPath, + TenantID: &leafID, + CompanyCode: companyCode, + AffiliationType: "AFFILIATE", + Status: "active", + UpdatedAt: time.Now(), + } if err := s.userRepo.Update(ctx, user); err != nil { - slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err) + slog.Error("Failed to update user in local DB", "email", email, "error", err) continue } - // 3. Sync Membership to Keto via Outbox + // 4. Sync Membership to Keto if s.ketoOutboxRepo != nil { - // Add as member of UserGroup (which is a Tenant namespace object) + // Add as member of the specific unit _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: leafID, @@ -139,18 +198,7 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R Action: domain.KetoOutboxActionCreate, }) - // [New] Also add as member of the root Tenant (for tenant-level member count) - if leafID != tenantID { - _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenantID, - Relation: "members", - Subject: "User:" + kratosID, - Action: domain.KetoOutboxActionCreate, - }) - } - - // Add as owner if applicable + // If owner/leader, assign owner role if isOwner { _ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", diff --git a/docs/organization-chart-policy.md b/docs/organization-chart-policy.md index d3fa77bf..aa6d85fd 100644 --- a/docs/organization-chart-policy.md +++ b/docs/organization-chart-policy.md @@ -1,88 +1,111 @@ -# Organization Chart Architecture & Implementation Policy (ADR) +# 조직도 기반 테넌트 및 권한 매핑 정책 (Organization Chart Policy) -## 1. Overview (개요) -본 문서는 Baron SSO 내 `adminfront`에서 사용될 **조직도(Organization Chart) 및 다중 테넌시(Multi-Tenancy) 대응 기능**에 대한 아키텍처 결정 사항(Architecture Decision Record)과 세부 구현 방향을 정의합니다. +이 문서는 실무 부서 및 직급 체계가 포함된 인사(HR) 데이터를 기반으로, Baron SSO의 다형성 테넌트(Polymorphic Tenant)와 Ory Keto 기반의 ReBAC 권한 모델을 어떻게 구축하고 동기화할 것인지에 대한 아키텍처 설계와 가이드라인을 정의합니다. -이 정책은 기존 B2B 테넌트 모델(`Tenant`)과 사내 사용자 그룹 모델(`UserGroup`), 그리고 Ory Keto 기반의 권한 제어(ReBAC) 시스템 간의 일관성을 유지하면서, 복잡하고 다양한 형태의 고객사별 조직 구조(N-Depth)를 지원하기 위해 작성되었습니다. +## 1. 개요 및 요구사항 분석 + +제공된 인사 데이터(샘플)는 다음과 같은 특징을 가집니다. + +| 연번 | 그룹 | 디비젼 | 팀 | 셀 | 직급 | 이름 | 직무 | 구분 | 소속 | +|---|---|---|---|---|---|---|---|---|---| +| 1 | 사장단 | - | - | - | 사장 | 정태원 | 사장단 | 센터장 | 한맥 | +| 4 | 엔지니어링 기획 | - | - | - | 부사장 | 양병홍 | 엔지니어 | 그룹장 | 삼안 | +| 5 | 엔지니어링 기획 | 일반구조물 | - | - | 수석 | 이동원 | 엔지니어 | 디비젼장 | 삼안 | +| 6 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 김일태 | 엔지니어 | 팀장 | 삼안 | +| 7 | 엔지니어링 기획 | 일반구조물 | 구조물계획 | - | 수석 | 곽현석 | 엔지니어 | 팀원 | 한맥 | + +### 🔍 주요 분석 포인트 (Matrix Organization) +가장 중요한 점은 6번(삼안 소속)과 7번(한맥 소속)이 **서로 다른 법인 소속임에도 불구하고 "엔지니어링 기획그룹 > 일반구조물 디비젼 > 구조물계획 팀" 이라는 동일한 논리적 부서에 속해 있다**는 것입니다. +이는 개별 법인(COMPANY) 산하에 부서가 종속되는 Tree 구조가 아니라, 지주사(COMPANY_GROUP) 차원에서 부서를 관리하고 법인 소속은 개인의 속성(Attribute)으로 분리해야 함을 의미합니다. --- -## 2. Core Architectural Decisions (핵심 아키텍처 결정) +## 2. 아키텍처 매핑 전략 (Data to DB) -### 2.1 B2B Tenant vs. Internal UserGroup Hierarchy (테넌트 vs. 유저그룹 계층화) -조직의 계층(Hierarchy)을 표현하기 위해 `Tenant` 자체를 중첩(Nested)시킬 것인지, 아니면 단일 `Tenant` 내의 `UserGroup`을 중첩시킬 것인지에 대한 결정입니다. +엑셀의 각 컬럼은 데이터베이스 모델과 다음과 같이 1:1로 매핑됩니다. -* **Decision (결정):** 조직도는 **`UserGroup` 내부의 자기 참조(`parent_id`)를 통해 계층화**합니다. -* **Rationale (이유):** - * **관심사 분리 (Separation of Concerns):** `Tenant` 모델은 결제, 도메인 매핑, B2B 고객사(Company) 격리 등 무거운 비즈니스 로직을 담고 있습니다. "개발팀", "인사부"와 같은 단순한 사내 조직 단위까지 `Tenant` 테이블에 저장하면 시스템 복잡도가 기하급수적으로 증가합니다. - * **조회 성능 (Performance):** 특정 고객사(Company)의 전체 조직도를 그릴 때 `SELECT * FROM user_groups WHERE tenant_id = ?` 단일 쿼리로 모든 노드를 가져와 애플리케이션 메모리에서 트리를 구성할 수 있어 성능상 매우 유리합니다. - * **단일 진실 공급원 (SoT):** 회사(Company) 단위의 물리적 격리는 `Tenant`가, 논리적인 사내 부서/팀 구조는 `UserGroup`이 담당하도록 역할을 명확히 분리합니다. +### 2.1 조직 (Tenant) 매핑 +모든 조직 단위는 `Tenant` 테이블에 저장되며 `type`과 `parent_id`로 계층을 구성합니다. -### 2.2 Flexible N-Depth Organizational Structure (유연한 N-Depth 조직 구조) -고객사마다 조직 단계(부, 국, 실, 본부, 파트, 반, 셀 등)의 명칭과 깊이(Depth)가 다릅니다. 이를 하드코딩된 Enum으로 제한해서는 안 됩니다. +* **소속 (한맥, 삼안):** `Tenant` (Type: `COMPANY`) - 법인 격리 공간 +* **그룹 / 디비젼 / 팀 / 셀:** `Tenant` (Type: `USER_GROUP`) - 논리적 사내 조직 +* **계층 연결:** 하위 조직(팀)의 `parent_id`는 상위 조직(디비젼)의 `id`를 참조합니다. -* **Decision (결정):** 조직의 단계나 명칭을 시스템(DB 스키마)에서 강제하지 않으며, **N-Depth 인접 목록(Adjacency List) 모델을 사용**합니다. -* **Implementation (구현):** - * `UserGroup` 모델에 `parent_id` (UUID, Nullable) 컬럼을 추가하여 부모-자식 관계를 형성합니다. - * 조직 타입(`unit_type`) 필드는 고정된 Enum(예: `TEAM`, `GROUP`) 대신, 고객사가 자유롭게 입력할 수 있는 **동적 문자열(`String`)**로 관리하거나, 계층의 상대적 깊이(Depth)만을 의미 단위로 사용합니다. - * 프론트엔드의 Checkbox Tree 컴포넌트는 재귀적(Recursive)으로 설계되어 데이터의 깊이에 상관없이 무한한 N-Depth를 렌더링할 수 있어야 합니다. +### 2.2 사용자 (User) 속성 매핑 +* **이름:** `User.Name` +* **직급 (사장, 부사장, 수석 등):** `User.Position` +* **직무 (엔지니어, 기획자 등):** `User.JobTitle` +* **소속 (법인 코드):** `User.CompanyCode` (`hanmac`, `saman` 등) +* **식별자:** (엑셀에 누락됨) 시스템 로그인을 위해 반드시 **이메일(Email) 또는 사번(LoginID)** 컬럼이 추가되어야 합니다. + +### 2.3 권한 및 역할 (Keto ReBAC) 매핑 +엑셀의 **구분(센터장, 그룹장, 디비젼장, 팀장, 팀원)** 컬럼은 해당 사용자가 조직 내에서 어떤 권한을 가지는지(Ory Keto의 Relation)를 결정합니다. + +* **리더 (장급):** 해당 조직 테넌트의 `owners` 또는 `admins` 튜플 부여. + * *예:* 양병홍 부사장은 `엔지니어링 기획그룹`의 `owners`가 됩니다. +* **팀원:** 가장 말단 조직 테넌트의 `members` 튜플 부여. + * *예:* 곽현석 수석은 `구조물계획 팀`의 `members`가 됩니다. --- -## 3. Data Structure & Schema Updates (데이터 구조 및 스키마 업데이트) +## 3. 다이어그램: 통합 조직도 계층 설계 -새로운 테이블을 추가하는 대신, 기존 모델을 확장하여 중복을 방지합니다. +아래는 위 전략을 바탕으로 구성된 지주사 통합 조직도와 권한 상속(Keto OPL) 다이어그램입니다. -### 3.1 `user_groups` 테이블 확장 -조직 계층 및 부서 단위 표현을 위해 필드를 추가합니다. -* `id`, `tenant_id`, `name`, `description` (기존 유지) -* **`parent_id` (UUID, Nullable FK):** 상위 `UserGroup` 참조 (조직 트리 구성). -* **`unit_type` (String, Optional):** 조직 단위 명칭 (예: "본부", "실", "팀"). 시스템이 강제하지 않으며 프론트엔드 라벨링 용도로 사용됩니다. +```mermaid +graph TD + %% 지주사 및 법인 + G[지주사
Type: COMPANY_GROUP] --> C1[한맥
Type: COMPANY] + G --> C2[삼안
Type: COMPANY] -### 3.2 `users` 테이블 확장 (직급 및 직무) -CSV에서 업로드되는 사용자의 인사 정보(직급, 직무 등)는 `User` 모델에 직접 저장합니다. -* **`position` (String):** 직급 (예: "수석", "책임", "사원"). -* **`job_title` (String):** 직무 (예: "프론트엔드 개발", "기획"). -* *(또는 기존에 존재하는 `Metadata` (JSONB) 필드를 활용하여 스키마 변경 없이 동적 속성으로 관리할 수도 있습니다.)* + %% 통합 조직도 (지주사 직속 논리적 연결) + G -.-> T1[전략기획그룹
Type: USER_GROUP] + G -.-> T2[엔지니어링 기획그룹
Type: USER_GROUP] + + T2 --> T2_1[일반구조물 디비젼
Type: USER_GROUP] + T2_1 --> T2_1_1[구조물계획 팀
Type: USER_GROUP] + + %% 유저 권한 매핑 (Keto Tuples) + U2([양병홍 / 삼안]) -. owners (그룹장) .-> T2 + U3([이동원 / 삼안]) -. owners (디비젼장) .-> T2_1 + U4([김일태 / 삼안]) -. owners (팀장) .-> T2_1_1 + U5([곽현석 / 한맥]) -. members (팀원) .-> T2_1_1 + + %% Keto OPL 상속 (부모의 권한이 자식으로 흐름) + T2 -. 부모/자식 상속 .-> T2_1 + T2_1 -. 부모/자식 상속 .-> T2_1_1 + + %% 결과적인 권한 도달 + U2 -. 자동 상속 (Read/Write) .-> T2_1_1 + + %% 스타일 + classDef company fill:#e3f2fd,stroke:#0277bd,stroke-width:2px; + classDef group fill:#fff3e0,stroke:#e65100,stroke-width:2px; + classDef user fill:#f3e5f5,stroke:#4a148c,stroke-width:1px; + + class G,C1,C2 company; + class T1,T2,T2_1,T2_1_1 group; + class U2,U3,U4,U5 user; +``` + +### 💡 ReBAC 상속의 이점 (OPL) +위 다이어그램에서 **양병홍 부사장(그룹장)**은 최상위 조직인 `엔지니어링 기획그룹`의 `owners`로 한 번만 매핑됩니다. +하지만 Keto의 `parents` 상속 설계 덕분에, 하위의 `일반구조물 디비젼`과 `구조물계획 팀`, 그리고 향후 생겨날 모든 하위 '셀' 단위까지 **자동으로 관리 권한(Read/Write)을 상속**받게 됩니다. 권한 부여 작업을 1회로 최소화할 수 있습니다. --- -## 4. ReBAC Integration Policy (Ory Keto 연동 정책) +## 4. 구축 파이프라인 (Bulk Import) 가이드 -DB의 `user_groups` 계층 트리는 Ory Keto의 관계 튜플(Tuple)과 동기화되어 권한 제어에 사용됩니다. (기존 통합 권한 정책 `tenant-usergroup-policy.md` 준수) +수백, 수천 명의 조직도를 수동으로 입력하는 것은 불가능하므로, 시스템은 **CSV 일괄 등록(Bulk Import)** API를 제공해야 합니다. -1. **조직 계층 동기화 (Hierarchy):** - * DB에서 A팀(`UserGroup`)이 B본부(`UserGroup`)의 하위로 설정되면, Keto에는 `UserGroup:#parent@UserGroup:` 튜플이 생성됩니다. -2. **소속원 매핑 (Membership):** - * 유저가 A팀에 속하면 `UserGroup:#members@User:<유저_ID>` 튜플이 생성됩니다. -3. **조직장 및 어드민 승격 (Leadership):** - * CSV 데이터 분석 또는 수동 지정을 통해 특정 유저가 A팀의 '조직장'으로 식별되면, `UserGroup:#owners@User:<유저_ID>` 튜플이 생성됩니다. - * 정책에 따라 `owners` 관계를 가진 유저는 해당 조직(UserGroup)과 그 하위 조직에 대한 `admins` 권한을 자동으로 상속받습니다. +1. **데이터 준비:** 엑셀 데이터를 CSV로 변환합니다. (반드시 이메일 또는 사번 컬럼 포함) +2. **조직(Tenant) 순차 생성 (Upsert):** + * 스크립트는 CSV의 그룹 ➔ 디비젼 ➔ 팀 ➔ 셀 순서로 읽으며, 없는 조직은 생성하고 상위 조직의 ID를 `parent_id`로 연결합니다. + * 생성 시 백엔드 `TenantService`는 자동으로 Keto에 `parents` 튜플을 동기화합니다. +3. **사용자(User) 계정 생성:** + * Ory Kratos에 계정을 생성하고(`POST /identities`), 로컬 DB `users` 테이블에 직급, 직무 등의 메타데이터를 저장합니다. +4. **멤버십(Keto 튜플) 매핑:** + * 사용자가 속한 **가장 깊은(Deepest) 말단 조직 단위 하나**를 찾습니다. + * 직책(장급/일반)에 따라 `owners` 또는 `members` 권한을 Keto에 부여합니다. ---- - -## 5. Data Loading & CSV Upload Strategy (데이터 로딩 및 CSV 업로드 전략) - -고정된 컬럼 구조는 다양한 회사의 조직도를 수용할 수 없으므로 유연한 파싱 로직이 필요합니다. - -### 5.1 Flexible CSV Format (유연한 CSV 포맷) -* **경로 기반 방식 (Path-based):** 조직 계층을 슬래시(`/`) 등으로 구분하여 하나의 문자열로 전달받습니다. - * *예시 컬럼:* `[조직_경로, 직급, 이름, 직무, 이메일]` - * *데이터 예시:* `"개발본부/클라우드실/플랫폼팀", "수석", "홍길동", "백엔드 개발", "hong@example.com"` -* **동적 뎁스 방식 (Dynamic Depth):** 뒤에서부터 고정된 사용자 속성 열(직급, 직무, 이름, 이메일 등)을 식별하고, 그 앞의 모든 열을 동적인 계층 구조로 해석합니다. - -### 5.2 Processing Flow (처리 흐름) -1. **Parsing & Validation:** 프론트엔드/백엔드에서 유연한 CSV 포맷을 파싱하고, `UserGroup` 계층 경로를 분석합니다. -2. **Tree Resolution:** 백엔드는 "개발본부 > 클라우드실 > 플랫폼팀" 경로를 DB에서 조회하거나 없으면 순차적으로 생성(`parent_id` 매핑)하여 `UserGroup` ID 트리를 완성합니다. -3. **User Upsert:** `User` 정보를 생성하거나 업데이트(`position`, `job_title` 갱신)합니다. -4. **Keto Synchronization:** DB 트랜잭션 완료 후, Background Worker가 변경된 조직 계층과 멤버십 정보를 기반으로 Ory Keto 튜플을 생성/삭제(Reconciliation)합니다. - ---- - -## 6. Frontend Multi-Tenancy UI (프론트엔드 다중 테넌트 UI) - -관리자가 여러 테넌트(Company)에 접근 권한이 있을 경우, 조직도를 명확히 구분하여 보여주어야 합니다. - -* **Tabs Interface:** 화면 상단 또는 측면에 사용자가 접근 가능한 최상위 `Tenant` 목록을 탭(Tabs) 형태로 제공합니다. -* **Scoped Fetching:** 특정 탭(Tenant)을 선택할 때마다 해당 `tenant_id`를 파라미터로 백엔드 API를 호출하여, 격리된 해당 회사만의 `UserGroup` 트리를 렌더링합니다. -* **Checkbox Tree Component:** Radix UI와 TailwindCSS를 기반으로 개발되며, N-Depth 중첩을 지원하고 부모-자식 간의 반선택(Indeterminate) 상태를 재귀적으로 계산하는 독립적인(Reusable) 컴포넌트로 구현됩니다. \ No newline at end of file +이 정책을 통해 복잡한 매트릭스 조직과 권한 체계를 단일 아키텍처로 우아하게 통합할 수 있습니다. From 6971b69b79ffc6f9189c7adc78677678d3884719 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 7 Apr 2026 16:00:14 +0900 Subject: [PATCH 09/18] feat(admin): add org chart bulk import button to main tenants list page (#500) --- .../tenants/routes/TenantListPage.tsx | 14 ++ backend/internal/service/org_chart_service.go | 138 +++++++++++++++--- 2 files changed, 128 insertions(+), 24 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 7963f81c..4b7b712c 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -13,6 +13,7 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; +import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; import { Table, TableBody, @@ -100,6 +101,9 @@ function TenantListPage() { const tenants = query.data?.items ?? []; + // [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts + const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0]; + const handleDelete = (tenantId: string, tenantName: string) => { if ( !window.confirm( @@ -130,6 +134,16 @@ function TenantListPage() {

+ {/* [New] Add Upload Modal to global list page, visible to Super Admin */} + + {rootTenant && ( + query.refetch()} + /> + )} + + - + {t("ui.admin.org.import_title", "조직도 일괄 등록")} @@ -97,64 +141,151 @@ ${example}`, {t( "msg.admin.org.import_description", - "CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", + "CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", )} -
-
- - - + {!result ? ( +
+
+ + + +
+ {file && ( +
+
+ +
+
{file.name}
+
+ {(file.size / 1024).toFixed(1)} KB +
+
+
+ {mutation.isPending && progressId && ( +
+
+ 데이터 처리 중... + + {percent}% ({progressData?.current || 0} /{" "} + {progressData?.total || 0}) + +
+
+
+
+
+ )} +
+ )}
- - {file && ( -
- -
-
{file.name}
-
- {(file.size / 1024).toFixed(1)} KB + ) : ( +
+
+
+
+ 전체 행 +
+
{result.totalRows}
+
+
+
+ 처리 완료 +
+
+ {result.processed} +
+
+
+
+ 사용자 생성/업데이트 +
+
+ {result.userCreated} / {result.userUpdated} +
+
+
+
+ 조직(테넌트) 생성 +
+
+ {result.tenantCreated}
- )} -
+ + {result.errors.length > 0 && ( +
+
+ + 오류 목록 ({result.errors.length}) +
+
+ {result.errors.map((err, idx) => ( +
+ {err} +
+ ))} +
+
+ )} +
+ )} - + {!result ? ( + + ) : ( + + )} diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 4b7b712c..e23ffe1c 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -13,7 +13,7 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; -import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; +import { Checkbox } from "../../../components/ui/checkbox"; import { Table, TableBody, @@ -25,13 +25,17 @@ import { import { type TenantSummary, deleteTenant, + deleteTenantsBulk, fetchMe, fetchTenants, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { OrgChartUploadModal } from "../components/OrgChartUploadModal"; function TenantListPage() { const navigate = useNavigate(); + const [selectedIds, setSelectedIds] = React.useState([]); + const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, @@ -41,7 +45,6 @@ function TenantListPage() { React.useEffect(() => { if (profile?.role === "tenant_admin") { const manageableCount = profile.manageableTenants?.length ?? 0; - // If only 1 in array, OR array is empty but we have a primary tenantId if ( (manageableCount === 1 || manageableCount === 0) && profile.tenantId @@ -67,6 +70,14 @@ function TenantListPage() { }, }); + const deleteBulkMutation = useMutation({ + mutationFn: (ids: string[]) => deleteTenantsBulk(ids), + onSuccess: () => { + setSelectedIds([]); + query.refetch(); + }, + }); + if ( profile && profile.role !== "super_admin" && @@ -84,7 +95,6 @@ function TenantListPage() { ); } - // While redirecting (only if exactly one manageable tenant) if ( profile?.role === "tenant_admin" && (profile.manageableTenants?.length ?? 0) <= 1 @@ -101,8 +111,40 @@ function TenantListPage() { const tenants = query.data?.items ?? []; - // [New] Find a primary COMPANY_GROUP tenant to act as the root for matrix org charts - const rootTenant = tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0]; + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(tenants.map((t) => t.id)); + } else { + setSelectedIds([]); + } + }; + + const handleSelect = (id: string, checked: boolean) => { + if (checked) { + setSelectedIds((prev) => [...prev, id]); + } else { + setSelectedIds((prev) => prev.filter((i) => i !== id)); + } + }; + + const handleDeleteBulk = () => { + if (selectedIds.length === 0) return; + if ( + !window.confirm( + t( + "msg.admin.tenants.delete_bulk_confirm", + "선택한 {{count}}개 테넌트를 삭제할까요?", + { count: selectedIds.length }, + ), + ) + ) { + return; + } + deleteBulkMutation.mutate(selectedIds); + }; + + const rootTenant = + tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0]; const handleDelete = (tenantId: string, tenantName: string) => { if ( @@ -134,16 +176,34 @@ function TenantListPage() {

- {/* [New] Add Upload Modal to global list page, visible to Super Admin */} - {rootTenant && ( - query.refetch()} - /> + {selectedIds.length > 0 && ( + )} + + query.refetch()} + /> + + + +