From 4e7f3e7235ccc1f282e2c0b11545107fdece982b Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 7 Apr 2026 11:14:45 +0900 Subject: [PATCH] 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` 상태가 아닐 경우 가입 진행을 차단합니다.