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

489 lines
14 KiB
Go

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)
// 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) {
// 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:<child_id>#parents@Tenant:<parent_id>
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]interface{})
}
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)
}
}
}
// 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,
})
// 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]interface{}, 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]interface{}, key string) []string {
if traits == nil {
return nil
}
switch value := traits[key].(type) {
case []string:
return value
case []interface{}:
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 {
switch state {
case "", "active":
return domain.UserStatusActive
case "inactive":
return domain.UserStatusInactive
default:
return 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:<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
}