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` 상태가 아닐 경우 가입 진행을 차단합니다.