forked from baron/baron-sso
내정보 멀티 테턴트 표시
This commit is contained in:
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user