1
0
forked from baron/baron-sso

feat: 구현: 유저 그룹 중심 권한 통합 및 미들웨어 정책 고도화

This commit is contained in:
2026-02-13 14:16:13 +09:00
parent b9ad54d459
commit 594fd24adb
37 changed files with 2611 additions and 1564 deletions

View File

@@ -164,7 +164,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
}
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
u, _ := url.Parse(fmt.Sprintf("%s/admin/relation-tuples", s.writeURL))
q := u.Query()
q.Set("namespace", namespace)
q.Set("object", object)

View File

@@ -1,130 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
)
type TenantGroupService interface {
CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error)
GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error)
ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error)
DeleteGroup(ctx context.Context, id string) error
AddTenantToGroup(ctx context.Context, groupID, tenantID string) error
RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error
AddGroupAdmin(ctx context.Context, groupID, userID string) error
RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
ListGroupAdmins(ctx context.Context, groupID string) ([]string, error)
}
type tenantGroupService struct {
repo repository.TenantGroupRepository
keto KetoService
}
func NewTenantGroupService(repo repository.TenantGroupRepository, keto KetoService) TenantGroupService {
return &tenantGroupService{repo: repo, keto: keto}
}
func (s *tenantGroupService) CreateGroup(ctx context.Context, name, slug, description string) (*domain.TenantGroup, error) {
group := &domain.TenantGroup{
Name: name,
Slug: slug,
Description: description,
}
if err := s.repo.Create(ctx, group); err != nil {
return nil, err
}
return group, nil
}
func (s *tenantGroupService) GetGroup(ctx context.Context, id string) (*domain.TenantGroup, error) {
return s.repo.FindByID(ctx, id)
}
func (s *tenantGroupService) ListGroups(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
return s.repo.List(ctx, limit, offset)
}
func (s *tenantGroupService) UpdateGroup(ctx context.Context, id string, name, description string) (*domain.TenantGroup, error) {
group, err := s.repo.FindByID(ctx, id)
if err != nil {
return nil, err
}
group.Name = name
group.Description = description
if err := s.repo.Update(ctx, group); err != nil {
return nil, err
}
return group, nil
}
func (s *tenantGroupService) DeleteGroup(ctx context.Context, id string) error {
return s.repo.Delete(ctx, id)
}
func (s *tenantGroupService) AddTenantToGroup(ctx context.Context, groupID, tenantID string) error {
if err := s.repo.AddTenant(ctx, groupID, tenantID); err != nil {
return err
}
// [Keto] ReBAC: Tenant -> Group membership
if s.keto != nil {
err := s.keto.CreateRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
if err != nil {
slog.Error("Failed to sync Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
}
}
return nil
}
func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error {
if err := s.repo.RemoveTenant(ctx, groupID, tenantID); err != nil {
return err
}
// [Keto] ReBAC: Remove Tenant -> Group membership
if s.keto != nil {
err := s.keto.DeleteRelation(ctx, "Tenant", tenantID, "parent_group", groupID)
if err != nil {
slog.Error("Failed to remove Keto relation for tenant group", "tenantID", tenantID, "groupID", groupID, "error", err)
}
}
return nil
}
func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
if s.keto == nil {
return nil
}
return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
}
func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
if s.keto == nil {
return nil
}
return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
}
func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
if s.keto == nil {
return []string{}, nil
}
tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
// subject_id is "User:uuid"
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
}
}
return userIDs, nil
}

View File

@@ -48,40 +48,54 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return nil, errors.New("keto service not initialized")
}
// 1. Get directly managed tenants
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
if err != nil {
slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
}
// 2. Get managed tenant groups
groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
if err != nil {
slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
}
// 3. Get tenants belonging to those groups
var groupInheritedTenantIDs []string
for _, groupID := range groupIDs {
// In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
// To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
// Wait, my ListObjects lists objects given a subject.
// So subject="TenantGroup:"+groupID+"#_"
// Object is Tenant ID.
ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
if err != nil {
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
}
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
var inheritedTenantIDs []string
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
for _, groupID := range allMyGroups {
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
if err == nil {
for _, t := range ts {
groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
for _, r := range relations {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
// view 권한도 관리 가능 목록에 포함 (필요 시)
relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
if err == nil {
for _, r := range relationsView {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
}
// Combine and deduplicate IDs
// 합산 및 중복 제거
allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs {
allIDsMap[id] = true
}
for _, id := range groupInheritedTenantIDs {
for _, id := range ownedGroupIDs {
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
}
for _, id := range inheritedTenantIDs {
allIDsMap[id] = true
}

View File

@@ -19,6 +19,7 @@ type UserGroupService interface {
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
}
@@ -26,14 +27,24 @@ type UserGroupService interface {
type userGroupService struct {
repo repository.UserGroupRepository
userRepo repository.UserRepository
tenantRepo repository.TenantRepository
ketoService KetoService
kratos *KratosAdminService
}
func NewUserGroupService(repo repository.UserGroupRepository, userRepo repository.UserRepository, keto KetoService) UserGroupService {
func NewUserGroupService(
repo repository.UserGroupRepository,
userRepo repository.UserRepository,
tenantRepo repository.TenantRepository,
keto KetoService,
kratos *KratosAdminService,
) UserGroupService {
return &userGroupService{
repo: repo,
userRepo: userRepo,
tenantRepo: tenantRepo,
ketoService: keto,
kratos: kratos,
}
}
@@ -70,36 +81,75 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
if err != nil {
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
// Return group without members rather than failing?
// But if we fail here, we might hide partial failure. Let's log and proceed or return error?
// For now, let's proceed with empty members to avoid blocking UI if keto is down?
// No, SSOT is Keto. If Keto is down, we can't show members.
// Returning error might be safer.
return nil, err
}
var userIDs []string
for _, t := range tuples {
// SubjectID is like "User:uuid"
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
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)
return nil, err
}
group.Members = members
// 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) {
return s.repo.ListByTenantID(ctx, tenantID)
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, "UserGroup", 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 {
@@ -124,6 +174,44 @@ func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID str
return nil
}
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
// Query: namespace=Tenant, subject=UserGroup:groupID#members
subject := "UserGroup:" + 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: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
// This means all members of the group have the relation on the tenant.