1
0
forked from baron/baron-sso

Merge branch 'feature/user-group' into dev

This commit is contained in:
2026-02-13 14:22:32 +09:00
31 changed files with 2690 additions and 1009 deletions

View File

@@ -244,10 +244,15 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
tenantService := service.NewTenantService(tenantRepo)
tenantService.SetKetoService(ketoService) // Keto 주입
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
// relyingPartyRepo removed as SSOT is now Hydra+Keto
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
@@ -255,12 +260,11 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -535,6 +539,22 @@ func main() {
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
// User Group Management (Tenant Admin/Super Admin)
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)

View File

@@ -34,6 +34,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},

View File

@@ -21,6 +21,12 @@ type UserGroup struct {
Members []User `gorm:"-" json:"members,omitempty"`
}
type GroupRole struct {
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
Relation string `json:"relation"`
}
func (ug *UserGroup) TableName() string {
return "user_groups"
}

View File

@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
id := c.Params("id")
group, err := h.Service.Get(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()})
}
return c.JSON(group)
}
@@ -93,3 +93,39 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
groupID := c.Params("id")
var req struct {
TenantID string `json:"tenantId"`
Relation string `json:"relation"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
groupID := c.Params("id")
roles, err := h.Service.ListRoles(c.Context(), groupID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(roles)
}
func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
groupID := c.Params("id")
tenantID := c.Params("tenantId")
relation := c.Params("relation")
if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -37,20 +37,38 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
}
// Get object ID from path (e.g., tenant ID)
objectID := c.Params("id")
if objectID == "" {
// Fix: For Tenant namespace, prioritize tenantId param if available
objectID := ""
if namespace == "Tenant" {
objectID = c.Params("tenantId")
}
if objectID == "" {
objectID = c.Params("id")
}
if objectID == "" {
slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
}
slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
// Set tenant_id for audit logging if namespace is Tenant
if namespace == "Tenant" {
c.Locals("tenant_id", objectID)
}
// Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed {
if err != nil {
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
}
if !allowed {
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID})
}
return c.Next()
@@ -136,7 +154,26 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
targetTenantID = c.Params("id") // common for /tenants/:id
}
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
if targetTenantID == "" {
return c.Next() // No target specified, let Keto or next handler decide
}
// Check primary tenant match
if profile.TenantID != nil && *profile.TenantID == targetTenantID {
return c.Next()
}
// Check inherited manageable tenants
isAllowed := false
for _, t := range profile.ManageableTenants {
if t.ID == targetTenantID {
isAllowed = true
break
}
}
if !isAllowed {
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: you do not have access to this tenant",
})

View File

@@ -37,7 +37,8 @@ func (r *userGroupRepository) Delete(ctx context.Context, id string) error {
func (r *userGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
var group domain.UserGroup
if err := r.db.WithContext(ctx).First(&group, "id = ?", id).Error; err != nil {
// Using Where to be more explicit and avoid issues with GORM's default primary key handling if ID is string/uuid
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&group).Error; err != nil {
return nil, err
}
return &group, nil

View File

@@ -169,7 +169,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

@@ -39,6 +39,74 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
return s.repo.FindByID(ctx, id)
}
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
// 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 direct tenants", "userID", userID, "error", err)
}
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
if err != nil {
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
}
// 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 _, 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)
}
}
}
// 합산 및 중복 제거
allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs {
allIDsMap[id] = true
}
for _, id := range ownedGroupIDs {
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
}
for _, id := range inheritedTenantIDs {
allIDsMap[id] = true
}
allIDs := make([]string, 0, len(allIDsMap))
for id := range allIDsMap {
allIDs = append(allIDs, id)
}
if len(allIDs) == 0 {
return []domain.Tenant{}, nil
}
return s.repo.FindByIDs(ctx, allIDs)
}
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
// Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok {

View File

@@ -17,19 +17,34 @@ type UserGroupService interface {
// 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
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,
}
}
@@ -66,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 {
@@ -119,3 +173,63 @@ 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.
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
}
return nil
}
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
}
return nil
}