1
0
forked from baron/baron-sso
Files
baron-sso/backend/internal/service/user_group_service.go

290 lines
8.0 KiB
Go

package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
)
type UserGroupService interface {
Create(ctx context.Context, group *domain.UserGroup) error
Update(ctx context.Context, group *domain.UserGroup) error
Delete(ctx context.Context, id string) error
Get(ctx context.Context, id string) (*domain.UserGroup, error)
List(ctx context.Context, tenantID 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, group *domain.UserGroup) error {
// [Polymorphic Tenant] Create corresponding Tenant record first
parentID := group.ParentID
if parentID == nil || *parentID == "" {
// If no parent user group, the parent is the company tenant
parentID = &group.TenantID
}
tenant := &domain.Tenant{
ID: group.ID, // Use same ID for 1:1 join
Type: domain.TenantTypeUserGroup,
ParentID: parentID,
Name: group.Name,
Slug: "ug-" + group.ID, // Temporary slug for user groups
Description: group.Description,
Status: domain.TenantStatusActive,
}
if group.ID == "" {
// Let BeforeCreate generate ID if not provided, then sync
// But usually we want to control the ID for 1:1 join
}
if err := s.tenantRepo.Create(ctx, tenant); err != nil {
slog.Error("Failed to create tenant record for user group", "error", err)
return err
}
// Update group.ID to match tenant.ID if it was generated
group.ID = tenant.ID
if err := s.repo.Create(ctx, group); err != nil {
return err
}
// Keto Hierarchy via Outbox: Tenant:<child_id>#parents@Tenant:<parent_id>
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: group.ID,
Relation: "parents",
Subject: "Tenant:" + *parentID,
Action: domain.KetoOutboxActionCreate,
})
}
return nil
}
func (s *userGroupService) Update(ctx context.Context, group *domain.UserGroup) error {
return s.repo.Update(ctx, group)
}
func (s *userGroupService) Delete(ctx context.Context, id string) error {
// Optional: Delete relations in Keto before DB delete
return s.repo.Delete(ctx, id)
}
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
}
// 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))
}
}
return groups, nil
}
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
// Keto via Outbox: Tenant:<groupID>#members@User:<userID>
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 {
// 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 {
// Keto via Outbox: Tenant:<tenantID>#<relation>@Tenant:<groupID>#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
}