1
0
forked from baron/baron-sso

사용자 테넌트 소속 데이터 정리

This commit is contained in:
2026-05-13 18:23:39 +09:00
parent 8a6e41d74c
commit e36a973053
26 changed files with 348 additions and 387 deletions

View File

@@ -256,6 +256,26 @@ func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string)
return nil, nil
}
func identityTenantAccessKeys(traits map[string]interface{}) []string {
keys := make([]string, 0, 2)
if tenantID := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "tenant_id"))); tenantID != "" {
keys = append(keys, tenantID)
}
if legacySlug := strings.ToLower(strings.TrimSpace(extractTraitString(traits, "companyCode"))); legacySlug != "" {
keys = append(keys, legacySlug)
}
return keys
}
func anyTenantKeyAllowed(keys []string, allowed map[string]bool) bool {
for _, key := range keys {
if allowed[key] {
return true
}
}
return false
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -590,7 +610,11 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
email := strings.TrimSpace(req.Email)
@@ -643,7 +667,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
"companyCode": req.CompanyCode,
}
// [Override with explicit LoginID if provided]
@@ -687,7 +710,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
attributes["role"] = role
attributes["companyCode"] = req.CompanyCode
if tenantID != "" {
attributes["tenant_id"] = tenantID
}
@@ -969,13 +991,17 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
tenantSlug, tenantSlugErr := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
results = append(results, bulkUserResult{Email: email, Success: false, Message: "email and name are required"})
continue
}
if tenantSlugErr != nil {
results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()})
continue
}
var tItem tenantCacheItem
var err error
@@ -1156,7 +1182,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
"companyCode": tenantSlug,
"tenant_id": tItem.ID,
"role": role,
}
@@ -1307,7 +1332,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
tenantSlug, err := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
var requesterRole string
var manageableSlugs []string
@@ -1481,7 +1509,11 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
if len(req.UserIDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
@@ -1539,9 +1571,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
}
// Authorization check
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if requester.Role == domain.RoleTenantAdmin {
if !manageableSlugs[userComp] {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
continue
}
@@ -1560,7 +1591,8 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["role"] = *req.Role
}
if req.CompanyCode != nil {
traits["companyCode"] = *req.CompanyCode
delete(traits, "companyCode")
delete(traits, "companyCodes")
// Resolve and update tenant_id in traits if changed
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
@@ -1702,8 +1734,7 @@ func (h *UserHandler) BulkDeleteUsers(c *fiber.Ctx) error {
// Authorization check
if requester.Role == domain.RoleTenantAdmin {
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
if !manageableSlugs[userComp] {
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), manageableSlugs) {
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden"})
continue
}
@@ -1767,8 +1798,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
allowed := map[string]bool{}
if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true
}
if requester.CompanyCode != "" {
allowed[strings.ToLower(requester.CompanyCode)] = true
}
for _, tenant := range requester.ManageableTenants {
allowed[strings.ToLower(tenant.ID)] = true
allowed[strings.ToLower(tenant.Slug)] = true
}
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot update user in another tenant")
}
}
@@ -1797,7 +1838,11 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
tenantSlug, err := tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, err.Error())
}
req.CompanyCode = tenantSlug
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
@@ -1866,24 +1911,6 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
delete(traits, "hanmacFamily")
delete(traits, "userType")
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
existingCodes = append(existingCodes, str)
}
}
}
// Keto에서 "실제" 소속 정보를 먼저 확인 (엑셀 임포트 사용자 대응)
if len(existingCodes) <= 1 && h.TenantService != nil {
if joined, err := h.TenantService.ListJoinedTenants(c.Context(), userID); err == nil {
for _, t := range joined {
existingCodes = append(existingCodes, t.Slug)
}
}
}
if req.Name != nil {
traits["name"] = strings.TrimSpace(*req.Name)
}
@@ -1898,29 +1925,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode)
if req.IsAddTenant {
// Add to existingCodes if not present
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
} else if req.IsRemoveTenant {
// Remove from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != code {
newCodes = append(newCodes, existing)
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the target tenant
if req.IsRemoveTenant {
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
go func(removedSlug string) {
bgCtx := context.Background()
@@ -1935,87 +1940,26 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}(code)
}
// If removing the primary company code, pick another one as primary if available
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary == code {
if len(existingCodes) > 0 {
traits["companyCode"] = existingCodes[0]
if h.TenantService != nil {
if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
traits["tenant_id"] = t.ID
}
}
} else {
traits["companyCode"] = ""
traits["tenant_id"] = ""
}
}
} else {
// Normal update (Move): replace primary company code and remove the old one from existingCodes
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary != "" && currentPrimary != code {
// Remove old primary from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != currentPrimary {
newCodes = append(newCodes, existing)
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
currentTenantID := extractTraitString(traits, "tenant_id")
if currentTenantID == tenant.ID {
traits["tenant_id"] = ""
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the old tenant
if h.TenantService != nil && h.KetoOutboxRepo != nil {
go func(removedSlug string) {
bgCtx := context.Background()
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
}(currentPrimary)
}
}
traits["companyCode"] = code
// Resolve TenantID for Kratos Trait
} else if !req.IsAddTenant {
if h.TenantService != nil && code != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
traits["tenant_id"] = tenant.ID
} else {
traits["tenant_id"] = ""
}
}
found := false
for _, existing := range existingCodes {
if existing == code {
found = true
break
}
}
if !found && code != "" {
existingCodes = append(existingCodes, code)
}
}
}
// Deduplicate and save back companyCodes
var codesToSave []string
seenCodes := map[string]bool{}
for _, c := range existingCodes {
if !seenCodes[c] && c != "" {
seenCodes[c] = true
codesToSave = append(codesToSave, c)
}
}
if len(codesToSave) > 0 {
traits["companyCodes"] = codesToSave
} else {
delete(traits, "companyCodes")
}
delete(traits, "companyCode")
delete(traits, "companyCodes")
if req.Department != nil {
traits["department"] = strings.TrimSpace(*req.Department)
@@ -2233,8 +2177,18 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
}
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if identity != nil {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
allowed := map[string]bool{}
if requester.TenantID != nil {
allowed[strings.ToLower(*requester.TenantID)] = true
}
if requester.CompanyCode != "" {
allowed[strings.ToLower(requester.CompanyCode)] = true
}
for _, tenant := range requester.ManageableTenants {
allowed[strings.ToLower(tenant.ID)] = true
allowed[strings.ToLower(tenant.Slug)] = true
}
if !anyTenantKeyAllowed(identityTenantAccessKeys(identity.Traits), allowed) {
return errorJSON(c, fiber.StatusForbidden, "forbidden: cannot delete user in another tenant")
}
}
@@ -2277,8 +2231,16 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
traits := identity.Traits
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "compCode", compCode)
tenantID := extractTraitString(traits, "tenant_id")
tenantSlug := ""
var tenantSummary *domain.Tenant
if tenantID != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenant(ctx, tenantID); err == nil && tenant != nil {
tenantSlug = tenant.Slug
tenantSummary = tenant
}
}
slog.Debug("Mapping identity", "email", extractTraitString(traits, "email"), "tenantID", tenantID, "tenantSlug", tenantSlug)
var customLoginIDs []string
if raw, ok := traits["custom_login_ids"]; ok {
@@ -2302,8 +2264,8 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
TenantSlug: compCode,
CompanyCode: compCode,
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
@@ -2326,7 +2288,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
// Otherwise, we put them in a "legacy" or "flat" bucket if needed, but for now let's keep them in summary.Metadata
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true,
"custom_login_ids": true, "id": true,
@@ -2341,11 +2303,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
summary.Metadata[k] = v
}
if compCode != "" && h.TenantService != nil {
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
summary.Tenant = tenant
}
}
summary.Tenant = tenantSummary
return summary
}
@@ -2357,10 +2315,6 @@ func (h *UserHandler) normalizePhoneNumber(phone string) string {
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
traits := identity.Traits
role := roleFromTraits(traits)
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
}
user := &domain.User{
ID: identity.ID,
@@ -2369,7 +2323,6 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),
Position: extractTraitString(traits, "position"),
@@ -2379,37 +2332,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt,
}
// [New] Sync multi-tenant codes
if codes, ok := traits["companyCodes"].([]interface{}); ok {
for _, v := range codes {
if str, ok := v.(string); ok && str != "" {
user.CompanyCodes = append(user.CompanyCodes, str)
}
}
} else if codes, ok := traits["companyCodes"].([]string); ok {
user.CompanyCodes = codes
}
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id")
if tID != "" {
user.TenantID = &tID
}
// 2. Fallback to slug lookup only if tenant_id trait is missing
if (user.TenantID == nil || *user.TenantID == "") && compCode != "" && h.TenantService != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
user.TenantID = &tenant.ID
}
}
// Metadata handling (exclude core fields)
user.Metadata = make(domain.JSONMap)
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"grade": true, "companyCode": true, "companyCodes": true, "department": true,
"position": true, "jobTitle": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
"custom_login_ids": true, "id": true,