package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "fmt" "log/slog" "github.com/google/uuid" ) type UserGroupService interface { Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) Get(ctx context.Context, id string) (*domain.UserGroup, error) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) Delete(ctx context.Context, tenantID, groupID string) error Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) // Member Management with Keto Sync AddMember(ctx context.Context, groupID, userID string) error RemoveMember(ctx context.Context, groupID, userID string) error // Permission Management ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error } type userGroupService struct { repo repository.UserGroupRepository userRepo repository.UserRepository tenantRepo repository.TenantRepository ketoService KetoService outboxRepo repository.KetoOutboxRepository kratos KratosAdminService } func NewUserGroupService( repo repository.UserGroupRepository, userRepo repository.UserRepository, tenantRepo repository.TenantRepository, keto KetoService, outbox repository.KetoOutboxRepository, kratos KratosAdminService, ) UserGroupService { return &userGroupService{ repo: repo, userRepo: userRepo, tenantRepo: tenantRepo, ketoService: keto, outboxRepo: outbox, kratos: kratos, } } func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) { // If no parent user group, the parent is the company tenant if parentID == nil || *parentID == "" { parentID = &tenantID } // Validate parent tenant exists if _, err := s.tenantRepo.FindByID(ctx, *parentID); err != nil { return nil, fmt.Errorf("parent tenant not found or invalid: %w", err) } unitID := uuid.NewString() // 1. Create Tenant (Type: USER_GROUP) groupTenant := &domain.Tenant{ ID: unitID, Type: domain.TenantTypeUserGroup, ParentID: parentID, Name: name, Slug: fmt.Sprintf("ug-%s", unitID[:8]), Description: description, Status: domain.TenantStatusActive, } if err := s.tenantRepo.Create(ctx, groupTenant); err != nil { slog.Error("Failed to create tenant record for user group", "error", err) return nil, err } // 2. Create UserGroup metadata group := &domain.UserGroup{ ID: unitID, TenantID: tenantID, ParentID: parentID, Name: name, Description: description, UnitType: unitType, } if err := s.repo.Create(ctx, group); err != nil { // Rollback Tenant creation? Or handle via cleanup job. For now, just log. slog.Error("Failed to create user group metadata after creating tenant", "tenantId", unitID, "error", err) return nil, err } // 3. Keto Hierarchy via Outbox: Tenant:#parents@Tenant: if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: unitID, Relation: "parents", Subject: "Tenant:" + *parentID, Action: domain.KetoOutboxActionCreate, }) } return group, nil } func (s *userGroupService) Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) { // Implementation for Update return nil, nil // Placeholder } func (s *userGroupService) Delete(ctx context.Context, tenantID, groupID string) error { // Implementation for Delete return nil // Placeholder } func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { group, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } // Fetch members from Keto (Tenant namespace) tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "") if err != nil { slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID) return nil, err } var userIDs []string for _, t := range tuples { sid := t.SubjectID if len(sid) > 5 && sid[:5] == "User:" { userIDs = append(userIDs, sid[5:]) } else { userIDs = append(userIDs, sid) } } if len(userIDs) > 0 { // 1. Try to find in local DB members, err := s.userRepo.FindByIDs(ctx, userIDs) if err != nil { slog.Error("Failed to fetch member details from db", "error", err) } // 2. Map existing DB members memberMap := make(map[string]domain.User) for _, m := range members { memberMap[m.ID] = m } // 3. For IDs not in DB, fetch from Kratos var finalMembers []domain.User for _, uid := range userIDs { if m, ok := memberMap[uid]; ok { finalMembers = append(finalMembers, m) } else if s.kratos != nil { // Fallback to Kratos identity, err := s.kratos.GetIdentity(ctx, uid) if err == nil && identity != nil { name, _ := identity.Traits["name"].(string) email, _ := identity.Traits["email"].(string) finalMembers = append(finalMembers, domain.User{ ID: uid, Name: name, Email: email, }) } } } group.Members = finalMembers } else { group.Members = []domain.User{} } return group, nil } func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) { groups, err := s.repo.ListByTenantID(ctx, tenantID) if err != nil { return nil, err } if s.ketoService == nil { return groups, nil } // For each group, fetch member count from Keto for i := range groups { tuples, err := s.ketoService.ListRelations(ctx, "Tenant", groups[i].ID, "members", "") if err == nil { // Create dummy members just to carry the count for the JSON response groups[i].Members = make([]domain.User, len(tuples)) } else { slog.Warn("Failed to fetch member count from Keto", "groupID", groups[i].ID, "error", err) groups[i].Members = []domain.User{} } } return groups, nil } func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error { // Validate group exists if _, err := s.repo.FindByID(ctx, groupID); err != nil { return fmt.Errorf("user group not found: %w", err) } // Keto via Outbox: Tenant:#members@User: if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: groupID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return nil } func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error { // Validate group exists if _, err := s.repo.FindByID(ctx, groupID); err != nil { return fmt.Errorf("user group not found: %w", err) } // Keto via Outbox: Delete relation if s.outboxRepo != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: groupID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionDelete, }) } return nil } func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) { // Query: namespace=Tenant, subject=Tenant:groupID#members subject := "Tenant:" + groupID + "#members" tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject) if err != nil { slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID) return nil, err } var roles []domain.GroupRole tenantIDs := make([]string, 0, len(tuples)) for _, t := range tuples { tenantIDs = append(tenantIDs, t.Object) } if len(tenantIDs) > 0 { tenantList, err := s.tenantRepo.FindByIDs(ctx, tenantIDs) if err != nil { slog.Error("Failed to fetch tenant details for roles", "error", err) } tenantMap := make(map[string]string) for _, t := range tenantList { tenantMap[t.ID] = t.Name } for _, t := range tuples { roles = append(roles, domain.GroupRole{ TenantID: t.Object, TenantName: tenantMap[t.Object], Relation: t.Relation, }) } } return roles, nil } func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error { // Validate group exists if _, err := s.repo.FindByID(ctx, groupID); err != nil { return fmt.Errorf("user group not found: %w", err) } // Keto via Outbox: Tenant:#@Tenant:#members if s.outboxRepo != nil { subject := "Tenant:" + groupID + "#members" _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: relation, Subject: subject, Action: domain.KetoOutboxActionCreate, }) } return nil } func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error { // Keto via Outbox: Delete relation if s.outboxRepo != nil { subject := "Tenant:" + groupID + "#members" _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenantID, Relation: relation, Subject: subject, Action: domain.KetoOutboxActionDelete, }) } return nil }