1
0
forked from baron/baron-sso

feat: 테넌트/RP 관리자 할당 UI 및 ReBAC 권한 검증 도구 구현 #244

This commit is contained in:
2026-02-11 13:26:26 +09:00
parent 8856485265
commit 68df43f3a8
24 changed files with 1547 additions and 48 deletions

View File

@@ -256,15 +256,16 @@ func main() {
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
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)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -559,6 +560,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
// Tenant Management (Super Admin Only)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
@@ -567,6 +569,9 @@ 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)
// Tenant Group Management (Super Admin Only)
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
@@ -576,9 +581,15 @@ func main() {
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)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners)
admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner)
admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner)
// Relying Party Management (Tenant Context)
admin.Post("/tenants/:tenantId/relying-parties",
@@ -703,4 +714,4 @@ func main() {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}
}

View File

@@ -1,22 +1,51 @@
package handler
import (
"baron-sso-backend/internal/service"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
)
type AdminHandler struct{}
type AdminHandler struct {
Keto service.KetoService
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
func NewAdminHandler(keto service.KetoService) *AdminHandler {
return &AdminHandler{Keto: keto}
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error {
namespace := c.Query("namespace")
object := c.Query("object")
relation := c.Query("relation")
subject := c.Query("subject")
if namespace == "" || object == "" || relation == "" || subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"})
}
allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"allowed": allowed,
"query": fiber.Map{
"namespace": namespace,
"object": object,
"relation": relation,
"subject": subject,
},
})
}
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats

View File

@@ -166,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
@@ -239,6 +244,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
}
// Set for audit logging
c.Locals("tenant_id", targetTenantID)
// Validate Permission
isAllowed := false
if profile.Role == domain.RoleSuperAdmin {
@@ -371,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
@@ -446,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
// Fetch first for audit log tenant_id
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
}
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -625,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
// 3. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)

View File

@@ -10,10 +10,11 @@ import (
type RelyingPartyHandler struct {
Service service.RelyingPartyService
UserSvc *service.KratosAdminService
}
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s}
func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
}
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
@@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error {
clientID := c.Params("id")
subjects, err := h.Service.ListOwners(c.Context(), clientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type ownerInfo struct {
Subject string `json:"subject"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Type string `json:"type"` // "user" or "group"
}
owners := make([]ownerInfo, 0, len(subjects))
for _, s := range subjects {
info := ownerInfo{Subject: s, Type: "unknown"}
if len(s) > 5 && s[:5] == "User:" {
info.Type = "user"
userID := s[5:]
identity, err := h.UserSvc.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
info.Name, _ = identity.Traits["name"].(string)
info.Email, _ = identity.Traits["email"].(string)
}
} else if len(s) > 10 && s[:10] == "UserGroup:" {
info.Type = "group"
// Group name enrichment could be added if we have a GroupService here
}
owners = append(owners, info)
}
return c.JSON(owners)
}
func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject") // e.g. "User:uuid"
if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner added"})
}
func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject")
if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner removed"})
}

View File

@@ -9,11 +9,12 @@ import (
)
type TenantGroupHandler struct {
Service service.TenantGroupService
Service service.TenantGroupService
UserService *service.KratosAdminService
}
func NewTenantGroupHandler(svc service.TenantGroupService) *TenantGroupHandler {
return &TenantGroupHandler{Service: svc}
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
return &TenantGroupHandler{Service: svc, UserService: userSvc}
}
type tenantGroupSummary struct {
@@ -120,6 +121,59 @@ func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) 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 {

View File

@@ -15,10 +15,11 @@ type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
UserSvc *service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc, Keto: keto}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
}
type tenantSummary struct {
@@ -327,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
tenantID := c.Params("id")
userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID)
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.UserSvc.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 {
admins = append(admins, adminInfo{ID: uid})
}
}
return c.JSON(admins)
}
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin added to tenant"})
}
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin removed from tenant"})
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {

View File

@@ -16,6 +16,9 @@ type RelyingPartyService interface {
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
Delete(ctx context.Context, clientID string) error
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
AddOwner(ctx context.Context, clientID, subject string) error
RemoveOwner(ctx context.Context, clientID, subject string) error
ListOwners(ctx context.Context, clientID string) ([]string, error)
}
type relyingPartyService struct {
@@ -163,6 +166,27 @@ func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clien
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
}
func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "")
if err != nil {
return nil, err
}
subjects := make([]string, 0, len(tuples))
for _, t := range tuples {
subjects = append(subjects, t.SubjectID)
}
return subjects, nil
}
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
if client == nil {
return nil

View File

@@ -15,6 +15,9 @@ type TenantGroupService interface {
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 {
@@ -92,3 +95,36 @@ func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID,
}
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

@@ -21,6 +21,9 @@ type TenantService interface {
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가
AddTenantAdmin(ctx context.Context, tenantID, userID string) error
RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error
ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error)
}
type tenantService struct {
@@ -208,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug)
}
func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "")
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
}
}
return userIDs, nil
}