forked from baron/baron-sso
feat: 구현: 유저 그룹 중심 권한 통합 및 미들웨어 정책 고도화
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user