package service import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/repository" "context" "fmt" "log/slog" "time" "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) SetWorksmobileSyncer(syncer WorksmobileSyncer) // 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 worksmobile WorksmobileSyncer } 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) SetWorksmobileSyncer(syncer WorksmobileSyncer) { s.worksmobile = syncer } func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) { // For Keto and Tenant hierarchy, if no parent group, the company tenant is the parent. actualParentID := parentID if actualParentID == nil || *actualParentID == "" { actualParentID = &tenantID } // Validate parent tenant exists if _, err := s.tenantRepo.FindByID(ctx, *actualParentID); err != nil { return nil, fmt.Errorf("parent tenant not found or invalid: %w", err) } unitID := uuid.NewString() // 1. Create Tenant (Type: ORGANIZATION) groupTenant := &domain.Tenant{ ID: unitID, Type: domain.TenantTypeOrganization, ParentID: actualParentID, 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 // parent_id in user_groups refers to other groups, so use original parentID (which might be nil) 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:" + *actualParentID, 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) populateMembers(ctx context.Context, group *domain.UserGroup) { 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) group.Members = []domain.User{} return } 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 { members, err := s.userRepo.FindByIDs(ctx, userIDs) if err != nil { slog.Error("Failed to fetch member details from db", "error", err) } memberMap := make(map[string]domain.User) for _, m := range members { memberMap[m.ID] = m } var finalMembers []domain.User for _, uid := range userIDs { if m, ok := memberMap[uid]; ok { finalMembers = append(finalMembers, m) } else if s.kratos != nil { 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{} } } 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 } s.populateMembers(ctx, group) 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 i := range groups { s.populateMembers(ctx, &groups[i]) } return groups, nil } func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error { // Validate group exists group, err := s.repo.FindByID(ctx, groupID) if err != nil { return fmt.Errorf("user group not found: %w", err) } var tenant *domain.Tenant if s.tenantRepo != nil { tenant, _ = s.tenantRepo.FindByID(ctx, group.TenantID) } var updatedIdentity *KratosIdentity // [Fix] Sync Kratos Traits & Local DB when a user is added to an organization if s.kratos != nil && tenant != nil { // Fetch Kratos Identity identity, err := s.kratos.GetIdentity(ctx, userID) if err == nil && identity != nil { traits := identity.Traits if traits == nil { traits = make(map[string]any) } delete(traits, "companyCode") delete(traits, "companyCodes") traits["tenant_id"] = tenant.ID traits["department"] = group.Name // Update Kratos updated, updateErr := s.kratos.UpdateIdentity(ctx, userID, traits, identity.State) if updateErr != nil { slog.Error("Failed to update identity traits during AddMember", "user", userID, "error", updateErr) } else if updated != nil { updatedIdentity = updated } else { identity.Traits = traits updatedIdentity = identity } } } // Sync local user repo if s.userRepo != nil && tenant != nil { localUser, err := s.userRepo.FindByID(ctx, userID) if err != nil || localUser == nil { if updatedIdentity != nil { localUser = mapUserGroupKratosIdentityToLocalUser(*updatedIdentity) } else { slog.Warn("Skipping local user sync during AddMember because identity projection is unavailable", "user", userID, "error", err) localUser = nil } } if localUser != nil { localUser.TenantID = &tenant.ID localUser.Department = group.Name if err := s.userRepo.Update(ctx, localUser); err != nil { slog.Error("Failed to sync local user during AddMember", "user", userID, "error", err) } else if s.worksmobile != nil { if err := s.worksmobile.EnqueueUserUpsertIfInScope(ctx, *localUser); err != nil { slog.Warn("Failed to enqueue Worksmobile user sync during AddMember", "user", userID, "error", 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, }) // Also add direct Tenant membership to Keto for member counting _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: group.TenantID, Relation: "members", Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) } return nil } func mapUserGroupKratosIdentityToLocalUser(identity KratosIdentity) *domain.User { traits := identity.Traits now := time.Now() createdAt := identity.CreatedAt if createdAt.IsZero() { createdAt = now } updatedAt := identity.UpdatedAt if updatedAt.IsZero() { updatedAt = now } role, ok := domain.NormalizeRoleAlias(userGroupTraitString(traits, "role")) if !ok { role, ok = domain.NormalizeRoleAlias(userGroupTraitString(traits, "grade")) if !ok { role = domain.RoleUser } } grade := userGroupTraitString(traits, "grade") if _, ok := domain.NormalizeRoleAlias(grade); ok { grade = "" } user := &domain.User{ ID: identity.ID, Email: userGroupTraitString(traits, "email"), Name: userGroupTraitString(traits, "name"), Phone: userGroupTraitString(traits, "phone_number"), Role: role, Status: userGroupIdentityStatus(identity.State), Department: userGroupTraitString(traits, "department"), Grade: grade, Position: userGroupTraitString(traits, "position"), JobTitle: userGroupTraitString(traits, "jobTitle"), AffiliationType: userGroupTraitString(traits, "affiliationType"), CreatedAt: createdAt, UpdatedAt: updatedAt, Metadata: make(domain.JSONMap), } if tenantID := userGroupTraitString(traits, "tenant_id"); tenantID != "" { user.TenantID = &tenantID } if relyingPartyID := userGroupTraitString(traits, "relying_party_id"); relyingPartyID != "" { user.RelyingPartyID = &relyingPartyID } coreTraits := map[string]bool{ "email": true, "name": true, "phone_number": true, "grade": true, "role": true, "companyCode": true, "company_code": true, "companyCodes": true, "tenant_id": true, "department": true, "position": true, "jobTitle": true, "affiliationType": true, "relying_party_id": true, "custom_login_ids": true, "id": true, } for key, value := range traits { if !coreTraits[key] { user.Metadata[key] = value } } return user } func userGroupTraitString(traits map[string]any, key string) string { if traits == nil { return "" } value, ok := traits[key] if !ok || value == nil { return "" } if str, ok := value.(string); ok { return str } return fmt.Sprint(value) } func userGroupTraitStringArray(traits map[string]any, key string) []string { if traits == nil { return nil } switch value := traits[key].(type) { case []string: return value case []any: items := make([]string, 0, len(value)) for _, item := range value { if str, ok := item.(string); ok && str != "" { items = append(items, str) } } return items default: return nil } } func userGroupIdentityStatus(state string) string { return domain.NormalizeUserStatus(state) } 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 }