forked from baron/baron-sso
테넌트 목록 및 조직 계층 구조 개선
This commit is contained in:
@@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
@@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Tenant Admin filtering
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
if requesterCompany == "" || compCode != requesterCompany {
|
||||
if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Search filtering
|
||||
// Dedicated companyCode filter
|
||||
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search filtering (Keyword search in email, name, or companyCode)
|
||||
if search != "" {
|
||||
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
|
||||
if !strings.Contains(email, searchLower) &&
|
||||
!strings.Contains(name, searchLower) &&
|
||||
!strings.Contains(strings.ToLower(compCode), searchLower) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
|
||||
// This ensures that member counts (which use local DB) eventually match reality
|
||||
if h.UserRepo != nil {
|
||||
go func(ids []service.KratosIdentity) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, identity := range ids {
|
||||
localUser := h.mapToLocalUser(identity)
|
||||
_ = h.UserRepo.Update(ctx, localUser)
|
||||
}
|
||||
}(filtered)
|
||||
}
|
||||
|
||||
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
}
|
||||
|
||||
// 2. Fallback to Local DB if Kratos is down (Development only recommended)
|
||||
// 2. Fallback to Local DB if Kratos is down
|
||||
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
||||
|
||||
// Fetch from UserRepo
|
||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
|
||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
|
||||
}
|
||||
@@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// [New] Local DB Sync
|
||||
localUser := &domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Phone: normalizePhoneNumber(req.Phone),
|
||||
AffiliationType: "internal",
|
||||
CompanyCode: req.CompanyCode,
|
||||
Department: req.Department,
|
||||
Role: role,
|
||||
Status: "active",
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if tenantID != "" {
|
||||
localUser.TenantID = &tenantID
|
||||
}
|
||||
|
||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||
if h.UserRepo != nil {
|
||||
go func(u *domain.User) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.UserRepo.Create(ctx, u); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err)
|
||||
}
|
||||
}(localUser)
|
||||
}
|
||||
|
||||
// [Keto] Sync relations via Outbox
|
||||
if h.KetoOutboxRepo != nil {
|
||||
// 1. Tenant Membership
|
||||
if localUser.TenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *localUser.TenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + identityID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
// 2. Role Specifics
|
||||
if role == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + identityID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *localUser.TenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + identityID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the newly created identity to ensure we have all traits
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
}
|
||||
|
||||
// [New] Local DB Sync - Ensure user exists in read-model
|
||||
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()
|
||||
|
||||
// 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
|
||||
if h.KetoOutboxRepo != nil {
|
||||
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
|
||||
}
|
||||
}(localUser, role, localUser.TenantID)
|
||||
}
|
||||
|
||||
response := h.mapIdentitySummary(c.Context(), *identity)
|
||||
if generatedPassword != "" {
|
||||
response.InitialPassword = generatedPassword
|
||||
@@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||
}
|
||||
|
||||
// Capture current local state for transition comparison
|
||||
var oldRole string
|
||||
var oldTenantID string
|
||||
if h.UserRepo != nil {
|
||||
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
|
||||
oldRole = local.Role
|
||||
if local.TenantID != nil {
|
||||
oldTenantID = *local.TenantID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
@@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Phone != nil {
|
||||
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
if phone == "" {
|
||||
delete(traits, "phone_number")
|
||||
} else {
|
||||
traits["phone_number"] = phone
|
||||
}
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
@@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// [New] Local DB Sync
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
||||
if h.UserRepo != nil {
|
||||
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
||||
oldRole := localUser.Role
|
||||
oldTenantID := ""
|
||||
if localUser.TenantID != nil {
|
||||
oldTenantID = *localUser.TenantID
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
localUser.Name = *req.Name
|
||||
}
|
||||
if req.Phone != nil {
|
||||
localUser.Phone = normalizePhoneNumber(*req.Phone)
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
localUser.CompanyCode = *req.CompanyCode
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
||||
localUser.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
if req.Department != nil {
|
||||
localUser.Department = *req.Department
|
||||
}
|
||||
if req.Role != nil {
|
||||
localUser.Role = *req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
localUser.Status = *req.Status
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
localUser.Metadata = req.Metadata
|
||||
}
|
||||
|
||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
|
||||
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.UserRepo.Update(ctx, u); err == nil {
|
||||
// [Keto Sync on Role Change] via Outbox
|
||||
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
|
||||
uID := u.ID
|
||||
newR := *rRole
|
||||
if oRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
if newR == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err)
|
||||
}
|
||||
}(localUser, req.Role, oldRole, oldTenantID)
|
||||
updatedLocalUser := h.mapToLocalUser(*updated)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
||||
go h.syncKetoRole(context.Background(), updatedLocalUser.ID,
|
||||
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
@@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||
return normalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
||||
traits := identity.Traits
|
||||
role := extractTraitString(traits, "grade")
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
compCode := extractTraitString(traits, "companyCode")
|
||||
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
|
||||
if compCode != "" && h.TenantService != nil {
|
||||
// Use a background context or a timeout-limited context for tenant lookup
|
||||
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
|
||||
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,
|
||||
}
|
||||
for k, v := range traits {
|
||||
if !coreTraits[k] {
|
||||
user.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
||||
// Remove old roles
|
||||
if oldRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oldTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// Add new roles
|
||||
if newRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *newTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
|
||||
Reference in New Issue
Block a user