package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "baron-sso-backend/internal/utils" "context" "errors" "log/slog" "strings" "gorm.io/gorm" ) type TenantService interface { RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 AddTenantAdmin(ctx context.Context, tenantID, userID string) error RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) } type tenantService struct { repo repository.TenantRepository keto KetoService } func NewTenantService(repo repository.TenantRepository) TenantService { return &tenantService{repo: repo} } func (s *tenantService) SetKetoService(keto KetoService) { s.keto = keto } func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { return s.repo.FindByID(ctx, id) } func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { if s.keto == nil { return nil, errors.New("keto service not initialized") } // 1. Get directly managed tenants directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID) if err != nil { slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err) } // 2. Get managed tenant groups groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID) if err != nil { slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err) } // 3. Get tenants belonging to those groups var groupInheritedTenantIDs []string for _, groupID := range groupIDs { // In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_ // To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_ // Wait, my ListObjects lists objects given a subject. // So subject="TenantGroup:"+groupID+"#_" // Object is Tenant ID. ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID) if err == nil { for _, t := range ts { groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object) } } } // Combine and deduplicate IDs allIDsMap := make(map[string]bool) for _, id := range directTenantIDs { allIDsMap[id] = true } for _, id := range groupInheritedTenantIDs { allIDsMap[id] = true } allIDs := make([]string, 0, len(allIDsMap)) for id := range allIDsMap { allIDs = append(allIDs, id) } if len(allIDs) == 0 { return []domain.Tenant{}, nil } return s.repo.FindByIDs(ctx, allIDs) } func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) } // 1. Check if slug exists existing, err := s.repo.FindBySlug(ctx, slug) if err == nil && existing != nil { return nil, errors.New("tenant slug already exists") } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } // 2. Create Tenant tenant := &domain.Tenant{ Name: name, Slug: slug, Description: description, Status: domain.TenantStatusActive, } if err := s.repo.Create(ctx, tenant); err != nil { return nil, err } // 3. Add Domains (Auto-verify for manual admin registration) for _, d := range domains { if err := s.repo.AddDomain(ctx, tenant.ID, d); err != nil { slog.Error("Failed to add domain to tenant", "tenant", slug, "domain", d, "error", err) } } return s.repo.FindBySlug(ctx, slug) } func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) } // Verify that adminEmail domain matches the requested domainName parts := strings.Split(adminEmail, "@") if len(parts) != 2 || parts[1] != domainName { return nil, errors.New("admin email domain must match the tenant domain") } tenant := &domain.Tenant{ Name: name, Slug: slug, Description: description, Status: domain.TenantStatusPending, Config: domain.JSONMap{"adminEmail": adminEmail}, } if err := s.repo.Create(ctx, tenant); err != nil { return nil, err } // Add Domain as unverified // TODO: Create a more nuanced AddDomain that takes 'verified' param // For now, Repo.AddDomain sets verified=true. I should fix Repo or just manually do it here if needed. // Let's fix Repo later. if err := s.repo.AddDomain(ctx, tenant.ID, domainName); err != nil { return nil, err } return tenant, nil } func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { tenant, err := s.repo.FindByID(ctx, id) if err != nil { return err } tenant.Status = domain.TenantStatusActive if err := s.repo.Update(ctx, tenant); err != nil { return err } // [Keto] Sync relation if s.keto != nil { // 테넌트 자체를 정의 (Zanzibar style) // 만약 신청 시 관리자 이메일이 있었다면 해당 사용자를 찾아 admin 권한 부여 시도 if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { slog.Info("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) // 여기서는 나중에 사용자가 가입할 때 처리하거나, 이미 가입된 사용자인지 확인 필요 // 우선 테넌트 관리자 관계 생성 로직은 사용자 가입/역할 변경 시점에 주로 발생하도록 설계 } } return nil } func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { tenant, err := s.repo.FindByDomain(ctx, emailDomain) if err != nil { return nil, err } // Only return ACTIVE tenants for auto-assignment if tenant.Status != domain.TenantStatusActive { return nil, errors.New("tenant is not active") } return tenant, nil } func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { if s.keto == nil { return errors.New("keto service not initialized") } return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) } func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { if s.keto == nil { return errors.New("keto service not initialized") } return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) } func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { if s.keto == nil { return nil, errors.New("keto service not initialized") } tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "") if err != nil { return nil, err } userIDs := make([]string, 0, len(tuples)) for _, t := range tuples { if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" { userIDs = append(userIDs, t.SubjectID[5:]) } } return userIDs, nil }