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

@@ -245,28 +245,25 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
tenantGroupRepo := repository.NewTenantGroupRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo)
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, ketoService)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
// relyingPartyRepo removed as SSOT is now Hydra+Keto
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
@@ -576,29 +573,18 @@ func main() {
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)
// Tenant Group Management (Super Admin Only)
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
admin.Post("/tenant-groups", requireSuperAdmin, tenantGroupHandler.CreateGroup)
admin.Get("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.GetGroup)
admin.Put("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.UpdateGroup)
admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup)
admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup)
admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup)
admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
// User Group Management (Tenant Admin/Super Admin)
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"))
userGroups.Get("/", userGroupHandler.List)
userGroups.Post("/", userGroupHandler.Create)
userGroups.Get("/:id", userGroupHandler.Get)
userGroups.Put("/:id", userGroupHandler.Update)
userGroups.Delete("/:id", userGroupHandler.Delete)
userGroups.Post("/:id/members", userGroupHandler.AddMember)
userGroups.Delete("/:id/members/:userId", userGroupHandler.RemoveMember)
userGroups.Post("/:id/roles", userGroupHandler.AssignRole)
userGroups.Delete("/:id/roles/:tenantId/:relation", userGroupHandler.RemoveRole)
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

@@ -31,10 +31,10 @@ func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
&domain.TenantGroup{},
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},

View File

@@ -25,18 +25,6 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
if t.ParentID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
}
if t.TenantGroupID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent_group", *t.TenantGroupID)
}
}
// 1.1 Sync Tenant Groups (Group Admins)
var groups []domain.TenantGroup
if err := db.Find(&groups).Error; err == nil {
slog.Info("Syncing tenant groups to Keto", "count", len(groups))
for range groups {
// 그룹 관리자 개념 확정 후 관계 생성 로직 추가 예정
}
}
// 2. Sync All Users

View File

@@ -17,47 +17,23 @@ const (
// Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (t *Tenant) IsActive() bool {
return t.Status == TenantStatusActive
}
// GetMergedConfig merges the group-level config with tenant-level config.
// Tenant config takes precedence.
func (t *Tenant) GetMergedConfig() JSONMap {
merged := make(JSONMap)
// 1. Apply Group Config (Base)
if t.TenantGroup != nil && t.TenantGroup.Config != nil {
for k, v := range t.TenantGroup.Config {
merged[k] = v
}
}
// 2. Apply Tenant Config (Overrides)
if t.Config != nil {
for k, v := range t.Config {
merged[k] = v
}
}
return merged
}
// BeforeCreate hook to generate UUID if not present.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {

View File

@@ -1,32 +0,0 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// TenantGroup represents a collection of tenants.
type TenantGroup struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Tenants []Tenant `gorm:"foreignKey:TenantGroupID" json:"tenants,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (tg *TenantGroup) TableName() string {
return "tenant_groups"
}
func (tg *TenantGroup) BeforeCreate(tx *gorm.DB) (err error) {
if tg.ID == "" {
tg.ID = uuid.NewString()
}
return
}

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

@@ -1,193 +0,0 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"time"
"github.com/gofiber/fiber/v2"
)
type TenantGroupHandler struct {
Service service.TenantGroupService
UserService *service.KratosAdminService
}
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
return &TenantGroupHandler{Service: svc, UserService: userSvc}
}
type tenantGroupSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Tenants []tenantSummary `json:"tenants,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
func (h *TenantGroupHandler) ListGroups(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
groups, total, err := h.Service.ListGroups(c.Context(), limit, offset)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
items := make([]tenantGroupSummary, 0, len(groups))
for _, g := range groups {
items = append(items, mapTenantGroupSummary(g))
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
"limit": limit,
"offset": offset,
})
}
func (h *TenantGroupHandler) GetGroup(c *fiber.Ctx) error {
id := c.Params("id")
group, err := h.Service.GetGroup(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
}
return c.JSON(mapTenantGroupSummary(*group))
}
func (h *TenantGroupHandler) CreateGroup(c *fiber.Ctx) error {
var req struct {
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
group, err := h.Service.CreateGroup(c.Context(), req.Name, req.Slug, req.Description)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.Status(fiber.StatusCreated).JSON(mapTenantGroupSummary(*group))
}
func (h *TenantGroupHandler) UpdateGroup(c *fiber.Ctx) error {
id := c.Params("id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"})
}
group, err := h.Service.UpdateGroup(c.Context(), id, req.Name, req.Description)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(mapTenantGroupSummary(*group))
}
func (h *TenantGroupHandler) DeleteGroup(c *fiber.Ctx) error {
id := c.Params("id")
if err := h.Service.DeleteGroup(c.Context(), id); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantGroupHandler) AddTenantToGroup(c *fiber.Ctx) error {
groupID := c.Params("id")
tenantID := c.Params("tenantId")
if err := h.Service.AddTenantToGroup(c.Context(), groupID, tenantID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "tenant added to group"})
}
func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) error {
groupID := c.Params("id")
tenantID := c.Params("tenantId")
if err := h.Service.RemoveTenantFromGroup(c.Context(), groupID, tenantID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "tenant removed from group"})
}
func (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
groupID := c.Params("id")
userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := make([]adminInfo, 0, len(userIDs))
for _, uid := range userIDs {
identity, err := h.UserService.GetIdentity(c.Context(), uid)
if err == nil && identity != nil {
name, _ := identity.Traits["name"].(string)
email, _ := identity.Traits["email"].(string)
admins = append(admins, adminInfo{
ID: uid,
Name: name,
Email: email,
})
} else {
// Fallback if identity not found in Kratos
admins = append(admins, adminInfo{ID: uid})
}
}
return c.JSON(admins)
}
func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
groupID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin added to group"})
}
func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
groupID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin removed from group"})
}
func mapTenantGroupSummary(g domain.TenantGroup) tenantGroupSummary {
tenants := make([]tenantSummary, 0, len(g.Tenants))
for _, t := range g.Tenants {
tenants = append(tenants, mapTenantSummary(t))
}
return tenantGroupSummary{
ID: g.ID,
Name: g.Name,
Slug: g.Slug,
Description: g.Description,
Tenants: tenants,
Config: g.Config,
CreatedAt: g.CreatedAt.Format(time.RFC3339),
UpdatedAt: g.UpdatedAt.Format(time.RFC3339),
}
}

View File

@@ -23,16 +23,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
}
type tenantSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
TenantGroupID *string `json:"tenantGroupId,omitempty"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Status string `json:"status"`
Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `string:"updatedAt"`
}
type tenantListResponse struct {
@@ -103,7 +102,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -126,7 +125,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
}
var tenant domain.Tenant
if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
}
@@ -211,7 +210,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
Slug *string `json:"slug"`
Description *string `json:"description"`
Status *string `json:"status"`
TenantGroupID *string `json:"tenantGroupId"`
Domains []string `json:"domains"`
Config map[string]any `json:"config"`
}
@@ -255,29 +253,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
tenant.Config = req.Config
}
// Handle Group Change
if req.TenantGroupID != nil {
oldGroupID := tenant.TenantGroupID
newGroupID := req.TenantGroupID
if *newGroupID == "" {
newGroupID = nil
}
// Update Keto if group changed
if h.Keto != nil {
// Remove old group relation if existed
if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
_ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
}
// Add new group relation
if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
_ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
}
}
tenant.TenantGroupID = newGroupID
}
if err := h.DB.Save(&tenant).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
@@ -387,16 +362,15 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
}
return tenantSummary{
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
Status: t.Status,
TenantGroupID: t.TenantGroupID,
Domains: domains,
Config: t.GetMergedConfig(),
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
ID: t.ID,
Name: t.Name,
Slug: t.Slug,
Description: t.Description,
Status: t.Status,
Domains: domains,
Config: t.Config,
CreatedAt: t.CreatedAt.Format(time.RFC3339),
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
}
}

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)
}
@@ -110,6 +110,15 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) 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")

View File

@@ -37,15 +37,23 @@ 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)
@@ -53,9 +61,14 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
// 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()
@@ -141,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

@@ -1,65 +0,0 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"gorm.io/gorm"
)
type TenantGroupRepository interface {
Create(ctx context.Context, group *domain.TenantGroup) error
Update(ctx context.Context, group *domain.TenantGroup) error
Delete(ctx context.Context, id string) error
FindByID(ctx context.Context, id string) (*domain.TenantGroup, error)
List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error)
AddTenant(ctx context.Context, groupID, tenantID string) error
RemoveTenant(ctx context.Context, groupID, tenantID string) error
}
type tenantGroupRepository struct {
db *gorm.DB
}
func NewTenantGroupRepository(db *gorm.DB) TenantGroupRepository {
return &tenantGroupRepository{db: db}
}
func (r *tenantGroupRepository) Create(ctx context.Context, group *domain.TenantGroup) error {
return r.db.WithContext(ctx).Create(group).Error
}
func (r *tenantGroupRepository) Update(ctx context.Context, group *domain.TenantGroup) error {
return r.db.WithContext(ctx).Save(group).Error
}
func (r *tenantGroupRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.TenantGroup{}, "id = ?", id).Error
}
func (r *tenantGroupRepository) FindByID(ctx context.Context, id string) (*domain.TenantGroup, error) {
var group domain.TenantGroup
if err := r.db.WithContext(ctx).Preload("Tenants").First(&group, "id = ?", id).Error; err != nil {
return nil, err
}
return &group, nil
}
func (r *tenantGroupRepository) List(ctx context.Context, limit, offset int) ([]domain.TenantGroup, int64, error) {
var groups []domain.TenantGroup
var total int64
db := r.db.WithContext(ctx).Model(&domain.TenantGroup{})
db.Count(&total)
if err := db.Limit(limit).Offset(offset).Find(&groups).Error; err != nil {
return nil, 0, err
}
return groups, total, nil
}
func (r *tenantGroupRepository) AddTenant(ctx context.Context, groupID, tenantID string) error {
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ?", tenantID).Update("tenant_group_id", groupID).Error
}
func (r *tenantGroupRepository) RemoveTenant(ctx context.Context, groupID, tenantID string) error {
return r.db.WithContext(ctx).Model(&domain.Tenant{}).Where("id = ? AND tenant_group_id = ?", tenantID, groupID).Update("tenant_group_id", nil).Error
}

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

@@ -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.