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) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) ApproveTenant(ctx context.Context, id string) error ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) SetKetoService(keto KetoService) DeleteTenantsBulk(ctx context.Context, ids []string) error } 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") } 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 { 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) ListJoinedTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { if s.keto == nil { return nil, errors.New("keto service not initialized") } memberIDs, err := s.keto.ListObjects(ctx, "Tenant", "members", "User:"+userID) if err != nil { slog.Error("Failed to list joined tenants from Keto", "userID", userID, "error", err) return []domain.Tenant{}, nil } ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID) adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID) idMap := make(map[string]bool) for _, id := range memberIDs { idMap[id] = true } for _, id := range ownerIDs { idMap[id] = true } for _, id := range adminIDs { 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) { if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) } 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 } 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 } if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "admins", Subject: "System:global#super_admins", Action: domain.KetoOutboxActionCreate, }) 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) } } if creatorID != "" { slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "owners", Subject: "User:" + creatorID, Action: domain.KetoOutboxActionCreate, }) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "admins", Subject: "User:" + creatorID, Action: domain.KetoOutboxActionCreate, }) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "members", Subject: "User:" + creatorID, Action: domain.KetoOutboxActionCreate, }) } } 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) { if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) } 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 } if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, Relation: "admins", Subject: "System:global#super_admins", Action: domain.KetoOutboxActionCreate, }) } 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 } 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) if s.userRepo != nil { user, err := s.userRepo.FindByEmail(ctx, adminEmail) if err == nil && user != nil { 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 } 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) { 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 } func (s *tenantService) ProvisionTenantByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) { groups, err := s.repo.ListByType(ctx, domain.TenantTypeCompanyGroup) if err != nil { return nil, err } for _, g := range groups { rawConfig, ok := g.Config["autoProvisioning"].(map[string]any) if !ok { continue } enabled, _ := rawConfig["enabled"].(bool) if !enabled { continue } mapping, ok := rawConfig["mappingRules"].(map[string]any) if !ok { continue } rule, ok := mapping[domainName].(map[string]any) if !ok { continue } slug, _ := rule["slug"].(string) name, _ := rule["name"].(string) if slug == "" || name == "" { continue } slog.Info("[Provisioning] Found rule for domain, creating sub-tenant", "domain", domainName, "parent", g.Slug, "newTenant", slug) return s.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "Automatically provisioned via group policy", []string{domainName}, &g.ID, "") } return nil, gorm.ErrRecordNotFound } func (s *tenantService) DeleteTenantsBulk(ctx context.Context, ids []string) error { if len(ids) == 0 { return nil } if err := s.repo.DeleteBulk(ctx, ids); err != nil { return err } if s.outboxRepo != nil { for _, id := range ids { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: id, Relation: "parents", Action: domain.KetoOutboxActionDelete, }) } } return nil }