forked from baron/baron-sso
feat(org): enhance bulk import to support multi-level hierarchy, auto-provision users, and map matrix organizations (#500)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -34,6 +35,7 @@ type KratosAdminService interface {
|
||||
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
|
||||
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
|
||||
DeleteIdentity(ctx context.Context, identityID string) error
|
||||
CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error)
|
||||
}
|
||||
|
||||
type kratosAdminService struct {
|
||||
@@ -239,6 +241,67 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
|
||||
if user == nil {
|
||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||
}
|
||||
|
||||
traits := map[string]interface{}{
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
}
|
||||
if user.PhoneNumber != "" {
|
||||
traits["phone_number"] = user.PhoneNumber
|
||||
}
|
||||
for k, v := range user.Attributes {
|
||||
if k == "id" || k == "email" {
|
||||
continue
|
||||
}
|
||||
traits[k] = v
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"schema_id": "default",
|
||||
"traits": traits,
|
||||
"credentials": map[string]interface{}{
|
||||
"password": map[string]interface{}{
|
||||
"config": map[string]string{
|
||||
"password": password,
|
||||
},
|
||||
},
|
||||
},
|
||||
"state": "active",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(payload)
|
||||
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return "", fmt.Errorf("kratos admin create identity failed status=%d body=%s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func hashPasswordForKratosAdmin(password string) (string, error) {
|
||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -48,22 +49,43 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
||||
return fmt.Errorf("failed to read CSV header: %w", err)
|
||||
}
|
||||
|
||||
// Map header columns
|
||||
// Map header columns (Support both English and Korean)
|
||||
colMap := make(map[string]int)
|
||||
for i, name := range header {
|
||||
colMap[strings.ToLower(strings.TrimSpace(name))] = i
|
||||
cleanName := strings.ToLower(strings.TrimSpace(name))
|
||||
colMap[cleanName] = i
|
||||
}
|
||||
|
||||
// Required columns
|
||||
required := []string{"email", "name", "organization", "position", "jobtitle"}
|
||||
for _, req := range required {
|
||||
if _, ok := colMap[req]; !ok {
|
||||
return fmt.Errorf("missing required column: %s", req)
|
||||
// Dynamic column detection for hierarchy
|
||||
hierarchyCols := []string{"그룹", "디비젼", "팀", "셀"}
|
||||
hierarchyIdx := make([]int, 0)
|
||||
for _, col := range hierarchyCols {
|
||||
if idx, ok := colMap[col]; ok {
|
||||
hierarchyIdx = append(hierarchyIdx, idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for created/found organization units to handle hierarchy efficiently
|
||||
// key: path (e.g. "HQ/Sales"), value: ID
|
||||
// Map English keys for core fields
|
||||
fieldMapping := map[string][]string{
|
||||
"email": {"email", "이메일"},
|
||||
"name": {"name", "이름"},
|
||||
"position": {"position", "직급"},
|
||||
"jobtitle": {"jobtitle", "직무"},
|
||||
"company": {"company", "소속"},
|
||||
"is_owner": {"is_owner", "구분"},
|
||||
}
|
||||
|
||||
actualMap := make(map[string]int)
|
||||
for key, aliases := range fieldMapping {
|
||||
for _, alias := range aliases {
|
||||
if idx, ok := colMap[alias]; ok {
|
||||
actualMap[key] = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Path cache for hierarchy
|
||||
pathCache := make(map[string]string)
|
||||
|
||||
for {
|
||||
@@ -76,61 +98,98 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
||||
continue
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(record[colMap["email"]])
|
||||
name := strings.TrimSpace(record[colMap["name"]])
|
||||
orgPath := strings.TrimSpace(record[colMap["organization"]])
|
||||
position := strings.TrimSpace(record[colMap["position"]])
|
||||
jobTitle := strings.TrimSpace(record[colMap["jobtitle"]])
|
||||
email := strings.TrimSpace(record[actualMap["email"]])
|
||||
name := strings.TrimSpace(record[actualMap["name"]])
|
||||
position := strings.TrimSpace(record[actualMap["position"]])
|
||||
jobTitle := strings.TrimSpace(record[actualMap["jobtitle"]])
|
||||
companyName := strings.TrimSpace(record[actualMap["company"]])
|
||||
|
||||
// Determine if owner (e.g. "팀장", "그룹장", "센터장", "실장")
|
||||
isOwner := false
|
||||
if idx, ok := colMap["is_owner"]; ok && idx < len(record) {
|
||||
val := strings.ToLower(record[idx])
|
||||
isOwner = val == "true" || val == "y" || val == "1" || val == "yes"
|
||||
if idx, ok := actualMap["is_owner"]; ok {
|
||||
val := record[idx]
|
||||
isOwner = strings.HasSuffix(val, "장") || strings.EqualFold(val, "true") || val == "1"
|
||||
}
|
||||
|
||||
if email == "" || name == "" || orgPath == "" {
|
||||
if email == "" || name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Process Organization Hierarchy
|
||||
leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure org path", "path", orgPath, "error", err)
|
||||
continue
|
||||
// 1. Process Hierarchy (Build path from multiple columns)
|
||||
var parts []string
|
||||
for _, idx := range hierarchyIdx {
|
||||
val := strings.TrimSpace(record[idx])
|
||||
if val != "" && val != "-" {
|
||||
parts = append(parts, val)
|
||||
}
|
||||
}
|
||||
orgPath := strings.Join(parts, "/")
|
||||
|
||||
// 2. Upsert User
|
||||
// Check if user exists in Kratos first (SoT)
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil || kratosID == "" {
|
||||
slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update User in Local DB (Read-Model)
|
||||
user, err := s.userRepo.FindByID(ctx, kratosID)
|
||||
if err != nil {
|
||||
// If not in local DB, create it
|
||||
user = &domain.User{
|
||||
ID: kratosID,
|
||||
Email: email,
|
||||
leafID := tenantID // Default to root
|
||||
if orgPath != "" {
|
||||
leafID, err = s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
|
||||
if err != nil {
|
||||
slog.Error("Failed to ensure hierarchy", "path", orgPath, "error", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
user.Name = name
|
||||
user.Position = position
|
||||
user.JobTitle = jobTitle
|
||||
user.Department = orgPath
|
||||
user.TenantID = &tenantID
|
||||
user.Status = "active"
|
||||
// 2. Ensure User exists in Kratos (Auto-create if missing)
|
||||
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
|
||||
if err != nil || kratosID == "" {
|
||||
slog.Info("User not found in Kratos, auto-creating...", "email", email)
|
||||
|
||||
// Map company name to slug (Simple mapping for now)
|
||||
companyCode := strings.ToLower(companyName)
|
||||
if companyCode == "한맥" { companyCode = "hanmac" }
|
||||
if companyCode == "삼안" { companyCode = "saman" }
|
||||
|
||||
brokerUser := &domain.BrokerUser{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Attributes: map[string]interface{}{
|
||||
"affiliationType": "AFFILIATE",
|
||||
"companyCode": companyCode,
|
||||
"department": orgPath,
|
||||
"grade": "member",
|
||||
},
|
||||
}
|
||||
// Default password for bulk import
|
||||
newID, createErr := s.kratos.CreateUser(ctx, brokerUser, "baron1234!@#")
|
||||
if createErr != nil {
|
||||
slog.Error("Failed to auto-create user in Kratos", "email", email, "error", createErr)
|
||||
continue
|
||||
}
|
||||
kratosID = newID
|
||||
}
|
||||
|
||||
// 3. Update User in Local DB
|
||||
companyCode := strings.ToLower(companyName)
|
||||
if companyCode == "한맥" { companyCode = "hanmac" }
|
||||
if companyCode == "삼안" { companyCode = "saman" }
|
||||
|
||||
user := &domain.User{
|
||||
ID: kratosID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Position: position,
|
||||
JobTitle: jobTitle,
|
||||
Department: orgPath,
|
||||
TenantID: &leafID,
|
||||
CompanyCode: companyCode,
|
||||
AffiliationType: "AFFILIATE",
|
||||
Status: "active",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
|
||||
slog.Error("Failed to update user in local DB", "email", email, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 3. Sync Membership to Keto via Outbox
|
||||
// 4. Sync Membership to Keto
|
||||
if s.ketoOutboxRepo != nil {
|
||||
// Add as member of UserGroup (which is a Tenant namespace object)
|
||||
// Add as member of the specific unit
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: leafID,
|
||||
@@ -139,18 +198,7 @@ func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.R
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
|
||||
// [New] Also add as member of the root Tenant (for tenant-level member count)
|
||||
if leafID != tenantID {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + kratosID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
|
||||
// Add as owner if applicable
|
||||
// If owner/leader, assign owner role
|
||||
if isOwner {
|
||||
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
|
||||
Reference in New Issue
Block a user