forked from baron/baron-sso
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.
This commit is contained in:
@@ -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 형태로 보관)
|
||||
|
||||
Reference in New Issue
Block a user