1
0
forked from baron/baron-sso

내정보 멀티 테턴트 표시

This commit is contained in:
2026-03-05 17:18:49 +09:00
parent c1479a32a7
commit 3113fc09ff
9 changed files with 446 additions and 262 deletions

View File

@@ -47,19 +47,22 @@ func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider OryProvi
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
Department string `json:"department"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
InitialPassword string `json:"initialPassword,omitempty"`
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
JoinedTenants []domain.Tenant `json:"joinedTenants,omitempty"` // [New] 다중 소속 테넌트 목록
Department string `json:"department"`
Position string `json:"position"`
JobTitle string `json:"jobTitle"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
InitialPassword string `json:"initialPassword,omitempty"`
}
type userListResponse struct {
@@ -222,8 +225,22 @@ func (h *UserHandler) GetUser(c *fiber.Ctx) error {
// [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role == domain.RoleTenantAdmin {
compCode := extractTraitString(identity.Traits, "companyCode")
if requester.CompanyCode == "" || compCode != requester.CompanyCode {
compCode := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
// Check if the target user's companyCode is in requester's manageable tenants
allowed := false
for _, t := range requester.ManageableTenants {
if strings.ToLower(t.Slug) == compCode {
allowed = true
break
}
}
// Also check primary company code
if !allowed && strings.ToLower(requester.CompanyCode) == compCode {
allowed = true
}
if !allowed {
return errorJSON(c, fiber.StatusForbidden, "forbidden: access to user in another tenant denied")
}
}
@@ -361,34 +378,27 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if h.UserRepo != nil {
localUser := h.mapToLocalUser(*identity)
// Sync to local DB
go func(u *domain.User, role string, tID *string) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Sync to local DB (Synchronous for immediate consistency)
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", localUser.Email, "error", err)
}
// Use Update (upsert) instead of Create for robustness
if err := h.UserRepo.Update(ctx, u); err != nil {
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
return
// [Keto] Sync relations via Outbox (Synchronous for accurate counting)
if h.KetoOutboxRepo != nil {
// 1. Role based relations
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
// 2. Direct membership to the Tenant (for accurate counting)
if localUser.TenantID != nil && *localUser.TenantID != "" {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "members",
Subject: "User:" + localUser.ID,
Action: domain.KetoOutboxActionCreate,
})
}
// [Keto] Sync relations via Outbox
if h.KetoOutboxRepo != nil {
// 1. Role based relations
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
// 2. Direct membership to the Tenant (for accurate counting)
if tID != nil && *tID != "" {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *tID,
Relation: "members",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}(localUser, role, localUser.TenantID)
}
}
response := h.mapIdentitySummary(c.Context(), *identity)
@@ -535,34 +545,50 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
continue
}
// Sync to local DB
// [CRITICAL FIX] Sync to local DB directly using current data
// Don't fetch from Kratos here as it might have propagation lag
if h.UserRepo != nil {
identity, _ := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if identity != nil {
localUser := h.mapToLocalUser(*identity)
// [Fix] Override with current loop data to ensure accuracy
localUser.CompanyCode = compCode
if tItem.ID != "" {
localUser.TenantID = &tItem.ID
}
localUser := &domain.User{
ID: identityID,
Email: email,
Name: name,
Phone: normalizePhoneNumber(item.Phone),
Role: role,
Status: "active",
CompanyCode: compCode,
Department: dept,
AffiliationType: "internal",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if tItem.ID != "" {
localUser.TenantID = &tItem.ID
}
_ = h.UserRepo.Update(context.Background(), localUser)
// Merge metadata
localUser.Metadata = make(domain.JSONMap)
for k, v := range item.Metadata {
localUser.Metadata[k] = v
}
if err := h.UserRepo.Update(c.Context(), localUser); err != nil {
slog.Error("Failed to sync bulk user to local DB", "email", email, "error", err)
}
if h.KetoOutboxRepo != nil {
// 1. Sync Role based relationship
h.syncKetoRole(c.Context(), localUser.ID, role, "", "", localUser.TenantID)
if h.KetoOutboxRepo != nil {
// 1. Sync Role based relationship
h.syncKetoRole(context.Background(), localUser.ID, role, "", "", localUser.TenantID)
// 2. Sync direct membership to the Tenant (for count)
if localUser.TenantID != nil && *localUser.TenantID != "" {
_ = h.KetoOutboxRepo.Create(context.Background(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "members",
Subject: "User:" + localUser.ID,
Action: domain.KetoOutboxActionCreate,
})
}
// 2. Sync direct membership to the Tenant (for count)
if localUser.TenantID != nil && *localUser.TenantID != "" {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "members",
Subject: "User:" + localUser.ID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
@@ -964,21 +990,45 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
}
}
// [Validation] Based on Tenant Schema
schemaCompCode := extractTraitString(identity.Traits, "companyCode")
if req.CompanyCode != nil {
schemaCompCode = *req.CompanyCode
}
if schemaCompCode != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode)
if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
// [Validation] Based on Tenant Schema (Multi-tenant aware)
isAdmin := requester != nil && (requester.Role == domain.RoleSuperAdmin || requester.Role == domain.RoleTenantAdmin)
// If metadata is namespaced (key is tenant ID), validate each namespace
// If it's flat, validate using schemaCompCode
for key, val := range req.Metadata {
// Basic check if key looks like a UUID (tenant ID)
if len(key) >= 32 {
// Namespaced metadata
if h.TenantService != nil {
tenant, err := h.TenantService.GetTenant(c.Context(), key)
if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
if subMeta, ok := val.(map[string]any); ok {
if err := h.validateMetadataWithAuth(subMeta, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed for tenant "+tenant.Name+": "+err.Error())
}
}
}
}
}
} else {
// Legacy/Flat metadata - validate using primary tenant schema
schemaCompCode := extractTraitString(identity.Traits, "companyCode")
if req.CompanyCode != nil {
schemaCompCode = *req.CompanyCode
}
if schemaCompCode != "" && h.TenantService != nil {
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), schemaCompCode)
if err == nil && tenant != nil {
if schema, ok := tenant.Config["userSchema"].([]interface{}); ok {
// For flat metadata, we validate the whole req.Metadata against this schema
if err := h.validateMetadataWithAuth(req.Metadata, schema, isAdmin, false); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "metadata validation failed: "+err.Error())
}
}
}
}
break // Only need to check flat metadata once
}
}
@@ -1019,21 +1069,16 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["role"] = role
}
// [Refined] Metadata synchronization: replace non-core traits with new Metadata
// [Namespaced Metadata Sync]
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
// 1. Remove existing non-core traits to handle deletions
for k := range traits {
if !coreTraits[k] {
delete(traits, k)
}
}
// 2. Add new metadata fields
// For namespaced metadata, we don't delete everything, we merge.
// But we should remove legacy flat traits that are not in the new req.Metadata if we want strict sync.
// For now, let's just merge.
for k, v := range req.Metadata {
if !coreTraits[k] {
traits[k] = v
@@ -1134,16 +1179,30 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
UpdatedAt: formatTime(identity.UpdatedAt),
}
// Filter out core traits and put everything else in Metadata
// [New] Fetch all manageable tenants (for Multi-tenancy support)
if h.TenantService != nil {
if joined, err := h.TenantService.ListManageableTenants(ctx, identity.ID); err == nil {
summary.JoinedTenants = joined
}
}
// [Namespaced Metadata] Handling
// We assume core traits are at the top level.
// For other keys, if they are UUIDs (tenant IDs), we treat them as namespaced metadata.
// 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,
"affiliationType": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
for k, v := range traits {
if !coreTraits[k] {
summary.Metadata[k] = v
if coreTraits[k] {
continue
}
// If the key is a tenant ID (uuid-like), it's namespaced metadata
// If not, it's flat metadata (for backward compatibility)
summary.Metadata[k] = v
}
if compCode != "" && h.TenantService != nil {
@@ -1165,7 +1224,11 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
if role == "" {
role = "user"
}
// Try "companyCode" first, then fallback to "company_code"
compCode := extractTraitString(traits, "companyCode")
if compCode == "" {
compCode = extractTraitString(traits, "company_code")
}
user := &domain.User{
ID: identity.ID,
@@ -1181,8 +1244,14 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt,
}
if compCode != "" && h.TenantService != nil {
// Use a background context or a timeout-limited context for tenant lookup
// 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 {
@@ -1190,12 +1259,12 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
}
}
// Metadata
// 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,
"affiliationType": true, "role": true, "tenant_id": true,
"affiliationType": true, "role": true, "tenant_id": true, "company_code": true,
}
for k, v := range traits {
if !coreTraits[k] {