forked from baron/baron-sso
492 lines
14 KiB
Go
492 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)
|
|
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:<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]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:<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]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:<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
|
|
}
|