forked from baron/baron-sso
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
395 lines
11 KiB
Go
395 lines
11 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)
|
|
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
|
|
}
|