1
0
forked from baron/baron-sso

fix(backend): prevent duplicate key constraint on empty login id when syncing users

This commit is contained in:
2026-03-31 13:11:32 +09:00
parent 4b34ab8161
commit 5029b8049b
6 changed files with 154 additions and 18 deletions

View File

@@ -496,9 +496,51 @@ 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 should probably reject if we want strictness,
// or just treat as GENERAL user. Given the risk "존재하지 않는 테넌트도 저장됨", we should reject.
return errorJSON(c, fiber.StatusBadRequest, "해당하는 가족사(테넌트)를 찾을 수 없습니다.")
// 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
}
}

View File

@@ -98,28 +98,31 @@ func TestSignup_CompanyCodeValidation(t *testing.T) {
})
mockRedis.On("Get", mock.Anything).Return(string(verifiedState), nil)
t.Run("Invalid Company Code", func(t *testing.T) {
t.Run("Create Tenant if CompanyCode Missing", func(t *testing.T) {
reqBody := domain.SignupRequest{
Email: "user@gmail.com", // General domain
Email: "user@gmail.com",
Password: "StrongPass123!",
Name: "Test User",
Phone: "010-1234-5678",
TermsAccepted: true,
CompanyCode: "non-existent-code",
CompanyCode: "new-slug",
}
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, "non-existent-code").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)
req := httptest.NewRequest("POST", "/signup", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
assert.Equal(t, "해당하는 가족사(테넌트)를 찾을 수 없습니다.", res["error"])
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("Active Company Code", func(t *testing.T) {

View File

@@ -940,6 +940,10 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
}
if localUser.LoginID == "" {
localUser.LoginID = localUser.ID
}
_ = h.UserRepo.Update(c.Context(), localUser)
// [Keto Sync]
@@ -1223,6 +1227,10 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if h.UserRepo != nil {
updatedLocalUser := h.mapToLocalUser(*updated)
if updatedLocalUser.LoginID == "" {
updatedLocalUser.LoginID = updatedLocalUser.ID
}
ctx := context.Background() // Use request context if appropriate, but sync must finish
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
@@ -1365,10 +1373,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
compCode = extractTraitString(traits, "company_code")
}
loginID := extractTraitString(traits, "id")
if loginID == "" {
// Fallback to UUID to prevent unique constraint violations on idx_tenant_login_id
// for users that use email/phone exclusively and don't have a specific loginId trait.
loginID = identity.ID
}
user := &domain.User{
ID: identity.ID,
Email: extractTraitString(traits, "email"),
LoginID: extractTraitString(traits, "id"),
LoginID: loginID,
Name: extractTraitString(traits, "name"),
Phone: extractTraitString(traits, "phone_number"),
Role: role,