1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/tenant_service.go
2026-03-05 17:20:46 +09:00

294 lines
9.2 KiB
Go

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, tenantType, description string, domains []string, parentID *string, creatorID 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)
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가
}
type tenantService struct {
repo repository.TenantRepository
userRepo repository.UserRepository
userGroupRepo repository.UserGroupRepository
keto KetoService
outboxRepo repository.KetoOutboxRepository
}
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository, userGroupRepo repository.UserGroupRepository, outboxRepo repository.KetoOutboxRepository) TenantService {
return &tenantService{
repo: repo,
userRepo: userRepo,
userGroupRepo: userGroupRepo,
outboxRepo: outboxRepo,
}
}
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")
}
// [Keto] 'Tenant' 네임스페이스에서 'manage' 권한을 가진 모든 테넌트 ID 조회
// OPL(parents 상속 포함) 결과가 반영된 리스트를 가져옵니다.
allIDs, err := s.keto.ListObjects(ctx, "Tenant", "manage", "User:"+userID)
if err != nil {
slog.Error("Failed to list manageable tenants from Keto", "userID", userID, "error", err)
return []domain.Tenant{}, nil
}
if len(allIDs) == 0 {
// Fallback: Check direct membership if list objects didn't catch everything
directAdminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
directOwnerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
idMap := make(map[string]bool)
for _, id := range directAdminIDs {
idMap[id] = true
}
for _, id := range directOwnerIDs {
idMap[id] = true
}
allIDs = make([]string, 0, len(idMap))
for id := range idMap {
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, tenantType, description string, domains []string, parentID *string, creatorID 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{
Type: tenantType,
Name: name,
Slug: slug,
Description: description,
Status: domain.TenantStatusActive,
ParentID: parentID,
}
if err := s.repo.Create(ctx, tenant); err != nil {
return nil, err
}
// [Keto] Sync hierarchy and ownership via Outbox
if s.outboxRepo != nil {
// Sync hierarchy
if tenant.ParentID != nil {
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
}); err != nil {
slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err)
}
}
// Sync creator ownership
if creatorID != "" {
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
// Add as owner
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "owners",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as admin
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as member
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
}
}
// 3. Add Domains (Auto-verify for manual admin registration)
for _, d := range domains {
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); 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{
Type: domain.TenantTypeCompany,
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
if err := s.repo.AddDomain(ctx, tenant.ID, domainName, false); 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 via Outbox
if s.outboxRepo != nil {
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
// Check if user already exists in our Read-Model
if s.userRepo != nil {
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
if err == nil && user != nil {
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "owners",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
} else {
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", 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) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
// Let the repository handle the query and pagination
return s.repo.List(ctx, limit, offset, parentID)
}
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
tenant, err := s.repo.FindByDomain(ctx, domainName)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return tenant != nil && tenant.Status == domain.TenantStatusActive, nil
}