forked from baron/baron-sso
Merge pull request 'feature/1183-signup-personal-default' (#1187) from feature/1183-signup-personal-default into dev
Reviewed-on: baron/baron-sso#1187
This commit is contained in:
@@ -712,69 +712,21 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 소속이 비어 있는 일반 가입자는 PERSONAL tenant를 자동 생성해 대표소속을 보장합니다.
|
||||
tenantSlug := strings.TrimSpace(req.TenantSlug)
|
||||
// 모든 온라인 가입자는 기본적으로 개인(Personal) 테넌트 소속으로 가입합니다.
|
||||
// 기업/가족사 소속 연동은 별도 문의를 통해 처리되므로 온라인 가입 흐름에서는 제외합니다.
|
||||
req.AffiliationType = "GENERAL"
|
||||
slog.Info("[Signup] Forcing AffiliationType to GENERAL (Default personal tenant signup policy)", "email", req.Email)
|
||||
|
||||
var tenantID *string
|
||||
|
||||
parts := strings.Split(req.Email, "@")
|
||||
if len(parts) != 2 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "Invalid email format")
|
||||
}
|
||||
domainName := parts[1]
|
||||
|
||||
// Check if this domain belongs to a predefined family affiliate
|
||||
isInternal, _ := h.isAffiliateTenant(c.Context(), domainName)
|
||||
|
||||
// [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)
|
||||
} else {
|
||||
req.AffiliationType = "GENERAL"
|
||||
slog.Info("[Signup] Forcing AffiliationType to GENERAL", "email", req.Email)
|
||||
}
|
||||
|
||||
if tenantSlug != "" {
|
||||
// [Security] Cross-check: If domain is NOT internal, they cannot provide a tenantSlug
|
||||
if !isInternal {
|
||||
slog.Warn("[Signup] Security violation: non-internal email providing tenantSlug", "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusForbidden, "Only affiliate members can join an organization.")
|
||||
}
|
||||
|
||||
if !affiliateSlugs[strings.ToLower(tenantSlug)] {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The selected organization is not a valid family affiliate.")
|
||||
}
|
||||
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), tenantSlug)
|
||||
if err == nil && tenant != nil {
|
||||
if tenant.Status == domain.TenantStatusActive {
|
||||
slog.Info("[Signup] Assigning tenant by manual slug", "email", req.Email, "tenant", tenant.Slug)
|
||||
tenantSlug = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
} else {
|
||||
return errorJSON(c, fiber.StatusForbidden, "The specified organization is not active.")
|
||||
}
|
||||
} else {
|
||||
slog.Warn("[Signup] Attempted to join non-existent organization", "slug", tenantSlug, "email", req.Email)
|
||||
return errorJSON(c, fiber.StatusNotFound, "The specified organization code was not found.")
|
||||
}
|
||||
} else {
|
||||
// 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 tenantID == nil && req.AffiliationType == "AFFILIATE" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "We couldn't verify your organization affiliation. Please check your choice.")
|
||||
}
|
||||
if tenantID == nil && req.AffiliationType == "GENERAL" {
|
||||
tenant, err := createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
|
||||
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), "personal")
|
||||
if err != nil || tenant == nil {
|
||||
// Fallback: 만약 시드된 personal 테넌트가 없을 경우 개인별 테넌트를 자동 생성합니다.
|
||||
tenant, err = createPersonalTenantForUser(c.Context(), h.TenantService, req.Email)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to create personal tenant")
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "failed to resolve personal tenant")
|
||||
}
|
||||
tenantSlug = tenant.Slug
|
||||
tenantID = &tenant.ID
|
||||
}
|
||||
tenantID = &tenant.ID
|
||||
|
||||
// Normalize Phone (E.164 형태로 보관)
|
||||
normalizedPhone := domain.NormalizePhoneNumber(req.Phone)
|
||||
|
||||
@@ -116,22 +116,19 @@ func TestSignup_TenantSlugValidation(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Active Tenant Slug", func(t *testing.T) {
|
||||
t.Run("Success creates Personal Tenant", func(t *testing.T) {
|
||||
reqBody := domain.SignupRequest{
|
||||
Email: "user@hanmaceng.co.kr",
|
||||
Password: "StrongPass123!",
|
||||
Name: "Test User",
|
||||
Phone: "010-1234-5678",
|
||||
TermsAccepted: true,
|
||||
TenantSlug: "hanmac",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
validTenant := &domain.Tenant{ID: "t1", Slug: "hanmac", Status: domain.TenantStatusActive}
|
||||
mockTenantSvc.On("GetTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(&domain.Tenant{Slug: "hanmac"}, nil).Once()
|
||||
mockTenantSvc.On("ProvisionTenantByDomain", mock.Anything, "hanmaceng.co.kr").Return(validTenant, nil).Maybe()
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "hanmac").Return(validTenant, nil).Once()
|
||||
mockTenantSvc.On("GetTenant", mock.Anything, "t1").Return(validTenant, nil).Once()
|
||||
validTenant := &domain.Tenant{ID: "personal-t1", Slug: "personal-slug", Status: domain.TenantStatusActive}
|
||||
mockTenantSvc.On("GetTenantBySlug", mock.Anything, "personal").Return((*domain.Tenant)(nil), assert.AnError).Once()
|
||||
mockTenantSvc.On("RegisterTenant", mock.Anything, "Personal - user@hanmaceng.co.kr", mock.Anything, domain.TenantTypePersonal, "Automatically provisioned personal tenant", []string(nil), (*string)(nil), "").Return(validTenant, nil).Once()
|
||||
mockIdp.On("CreateUser", mock.Anything, mock.Anything).Return("user-id", nil).Once()
|
||||
mockRedis.On("Delete", mock.Anything).Return(nil)
|
||||
|
||||
|
||||
@@ -135,6 +135,9 @@ func createPersonalTenantForUser(ctx context.Context, tenantService service.Tena
|
||||
normalizedEmail = "user"
|
||||
}
|
||||
slug := "personal-" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if len(slug) > 32 {
|
||||
slug = slug[:32]
|
||||
}
|
||||
tenant, err := tenantService.RegisterTenant(
|
||||
ctx,
|
||||
fmt.Sprintf("Personal - %s", normalizedEmail),
|
||||
|
||||
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal file
48
backend/internal/handler/tenant_assignment_policy_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestCreatePersonalTenantForUser_SlugLength(t *testing.T) {
|
||||
mockTenantService := &MockTenantService{}
|
||||
ctx := context.Background()
|
||||
|
||||
var capturedSlug string
|
||||
mockTenantService.On(
|
||||
"RegisterTenant",
|
||||
ctx,
|
||||
"Personal - user@example.com",
|
||||
mock.AnythingOfType("string"),
|
||||
domain.TenantTypePersonal,
|
||||
"Automatically provisioned personal tenant",
|
||||
[]string(nil),
|
||||
(*string)(nil),
|
||||
"",
|
||||
).Run(func(args mock.Arguments) {
|
||||
capturedSlug = args.String(2)
|
||||
}).Return(&domain.Tenant{
|
||||
ID: "personal-tenant-id",
|
||||
Slug: "personal-slug",
|
||||
Name: "Personal - user@example.com",
|
||||
}, nil)
|
||||
|
||||
tenant, err := createPersonalTenantForUser(ctx, mockTenantService, "user@example.com")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
|
||||
// Ensure the generated slug is strictly 32 characters or less
|
||||
assert.LessOrEqual(t, len(capturedSlug), 32)
|
||||
assert.True(t, strings.HasPrefix(capturedSlug, "personal-"))
|
||||
|
||||
// Ensure that the captured slug actually passes ValidateSlug!
|
||||
valid, msg := utils.ValidateSlug(capturedSlug)
|
||||
assert.True(t, valid, "Slug must be valid: "+msg)
|
||||
}
|
||||
@@ -97,6 +97,8 @@ type tenantPermissions struct {
|
||||
ManageOrganization bool `json:"manage_organization"`
|
||||
ViewSchema bool `json:"view_schema"`
|
||||
ManageSchema bool `json:"manage_schema"`
|
||||
ViewWorksmobile bool `json:"view_worksmobile"`
|
||||
ManageWorksmobile bool `json:"manage_worksmobile"`
|
||||
}
|
||||
|
||||
type tenantSummary struct {
|
||||
@@ -1742,6 +1744,8 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
ManageOrganization: true,
|
||||
ViewSchema: true,
|
||||
ManageSchema: true,
|
||||
ViewWorksmobile: true,
|
||||
ManageWorksmobile: true,
|
||||
}
|
||||
} else {
|
||||
// Query Keto in parallel for maximum performance
|
||||
@@ -1751,13 +1755,14 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
allowed bool
|
||||
err error
|
||||
}
|
||||
ch := make(chan checkResult, 11)
|
||||
ch := make(chan checkResult, 13)
|
||||
relations := []string{
|
||||
"view", "manage", "manage_admins",
|
||||
"view_profile", "manage_profile",
|
||||
"view_permissions", "manage_permissions",
|
||||
"view_organization", "manage_organization",
|
||||
"view_schema", "manage_schema",
|
||||
"view_worksmobile", "manage_worksmobile",
|
||||
}
|
||||
for _, rel := range relations {
|
||||
go func(r string) {
|
||||
@@ -1796,6 +1801,10 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
perms.ViewSchema = res.allowed
|
||||
case "manage_schema":
|
||||
perms.ManageSchema = res.allowed
|
||||
case "view_worksmobile":
|
||||
perms.ViewWorksmobile = res.allowed
|
||||
case "manage_worksmobile":
|
||||
perms.ManageWorksmobile = res.allowed
|
||||
}
|
||||
}
|
||||
summary.UserPermissions = perms
|
||||
|
||||
Reference in New Issue
Block a user