1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/org_chart_service.go

268 lines
6.7 KiB
Go

package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"encoding/csv"
"fmt"
"io"
"log/slog"
"strings"
"github.com/google/uuid"
)
type OrgChartService interface {
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
}
type orgChartService struct {
tenantRepo repository.TenantRepository
userGroupRepo repository.UserGroupRepository
userRepo repository.UserRepository
ketoOutboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
}
func NewOrgChartService(
tenantRepo repository.TenantRepository,
userGroupRepo repository.UserGroupRepository,
userRepo repository.UserRepository,
ketoOutbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) OrgChartService {
return &orgChartService{
tenantRepo: tenantRepo,
userGroupRepo: userGroupRepo,
userRepo: userRepo,
ketoOutboxRepo: ketoOutbox,
kratos: kratos,
}
}
func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return fmt.Errorf("failed to read CSV header: %w", err)
}
// Map header columns
colMap := make(map[string]int)
for i, name := range header {
colMap[strings.ToLower(strings.TrimSpace(name))] = 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)
}
}
// Cache for created/found organization units to handle hierarchy efficiently
// key: path (e.g. "HQ/Sales"), value: ID
pathCache := make(map[string]string)
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
slog.Error("Failed to read CSV record", "error", err)
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"]])
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 email == "" || name == "" || orgPath == "" {
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
}
// 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,
}
}
user.Name = name
user.Position = position
user.JobTitle = jobTitle
user.Department = orgPath
user.TenantID = &tenantID
user.Status = "active"
if err := s.userRepo.Update(ctx, user); err != nil {
slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
continue
}
// 3. Sync Membership to Keto via Outbox
if s.ketoOutboxRepo != nil {
// Add as member of UserGroup (which is a Tenant namespace object)
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
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 isOwner {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "owners",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
}
return nil
}
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
parts := strings.Split(path, "/")
currentParentID := rootTenantID
currentPath := ""
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
if id, ok := cache[currentPath]; ok {
currentParentID = id
continue
}
// Check DB if already exists
var existingID string
if s.userGroupRepo != nil {
groups, err := s.userGroupRepo.ListByTenantID(ctx, rootTenantID)
if err == nil {
for _, g := range groups {
// Match by name and parent
if g.Name == part && ((g.ParentID == nil && currentParentID == rootTenantID) || (g.ParentID != nil && *g.ParentID == currentParentID)) {
existingID = g.ID
break
}
}
}
}
if existingID == "" {
// Create new unit
unitID := uuid.NewString()
// 1. Create Tenant (Type: USER_GROUP)
newTenant := &domain.Tenant{
ID: unitID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: fmt.Sprintf("ug-%s", unitID[:8]),
Status: domain.TenantStatusActive,
}
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
return "", err
}
// 2. Create UserGroup metadata
newUserGroup := &domain.UserGroup{
ID: unitID,
TenantID: rootTenantID,
ParentID: &currentParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
return "", err
}
// 3. Sync Hierarchy to Keto via Outbox
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: unitID,
Relation: "parents",
Subject: "Tenant:" + currentParentID,
Action: domain.KetoOutboxActionCreate,
})
}
existingID = unitID
}
cache[currentPath] = existingID
currentParentID = existingID
}
return currentParentID, nil
}
func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 {
return "Team"
}
if index == 0 {
return "Division"
}
if index == total-1 {
return "Team"
}
return "Department"
}