1
0
forked from baron/baron-sso

refactor: backend tenant_group 제거 및 관련 정리

This commit is contained in:
Lectom C Han
2026-02-12 22:12:57 +09:00
parent d590acfff9
commit b0792113ae
7 changed files with 0 additions and 673 deletions

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

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

@@ -1,123 +0,0 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/service"
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Reusing MockKetoService from previous step or defining here if needed
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
return m.Called(ctx, namespace, object, relation, subject).Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]service.RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Get(0).([]service.RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
return args.Get(0).([]string), args.Error(1)
}
// MockAuthHandler implements middleware.AuthProfileProvider
type MockAuthHandler struct {
mock.Mock
}
func (m *MockAuthHandler) GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
args := m.Called(c)
return args.Get(0).(*domain.UserProfileResponse), args.Error(1)
}
func TestRequireKetoPermission_Tenant_AuditContext(t *testing.T) {
app := fiber.New()
mockKeto := new(MockKetoService)
mockAuth := new(MockAuthHandler)
config := middleware.RBACConfig{
AuthHandler: mockAuth,
KetoService: mockKeto,
}
userID := "user-1"
tenantID := "tenant-abc"
// Mock user profile
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: userID,
Role: domain.RoleTenantAdmin,
}, nil)
// Mock Keto: Allow access
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "manage").Return(true, nil)
// Route with middleware
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "manage"), func(c *fiber.Ctx) error {
// Verify that tenant_id was injected into Locals for audit log
assert.Equal(t, tenantID, c.Locals("tenant_id"))
return c.SendStatus(fiber.StatusOK)
})
// Execute
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
resp, _ := app.Test(req)
// Verify
assert.Equal(t, http.StatusOK, resp.StatusCode)
mockKeto.AssertExpectations(t)
mockAuth.AssertExpectations(t)
}
func TestRequireKetoPermission_Deny(t *testing.T) {
app := fiber.New()
mockKeto := new(MockKetoService)
mockAuth := new(MockAuthHandler)
config := middleware.RBACConfig{
AuthHandler: mockAuth,
KetoService: mockKeto,
}
userID := "user-bad"
tenantID := "tenant-secret"
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(&domain.UserProfileResponse{
ID: userID,
Role: domain.RoleUser,
}, nil)
// Mock Keto: Deny access
mockKeto.On("CheckPermission", mock.Anything, userID, "Tenant", tenantID, "view").Return(false, nil)
app.Get("/test/tenants/:id", middleware.RequireKetoPermission(config, "Tenant", "view"), func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
req := httptest.NewRequest("GET", "/test/tenants/"+tenantID, nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}

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

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

@@ -1,108 +0,0 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockTenantRepository is a mock implementation of repository.TenantRepository
type MockTenantRepository struct {
mock.Mock
}
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
args := m.Called(ctx, slug)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByName(ctx context.Context, name string) (*domain.Tenant, error) {
args := m.Called(ctx, name)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) {
args := m.Called(ctx, domainName)
return args.Get(0).(*domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids)
return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, domainName string) error {
return m.Called(ctx, tenantID, domainName).Error(0)
}
func TestTenantService_ListManageableTenants_Inheritance(t *testing.T) {
mockRepo := new(MockTenantRepository)
mockKeto := new(MockKetoService)
svc := &tenantService{
repo: mockRepo,
keto: mockKeto,
}
userID := "user-123"
ctx := context.Background()
// 1. Mock direct tenant management (admins relation)
mockKeto.On("ListObjects", ctx, "Tenant", "admins", userID).Return([]string{"t-direct-1"}, nil)
// 2. Mock group management (admins of a group)
mockKeto.On("ListObjects", ctx, "TenantGroup", "admins", userID).Return([]string{"g-1"}, nil)
// 3. Mock tenants belonging to group g-1
mockKeto.On("ListRelations", ctx, "Tenant", "", "parent_group", "TenantGroup:g-1").Return([]RelationTuple{
{Object: "t-inherited-1", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
{Object: "t-inherited-2", Relation: "parent_group", SubjectID: "TenantGroup:g-1"},
}, nil)
// 4. Expect repository to fetch all unique IDs: t-direct-1, t-inherited-1, t-inherited-2
expectedIDs := []string{"t-direct-1", "t-inherited-1", "t-inherited-2"}
mockRepo.On("FindByIDs", ctx, mock.MatchedBy(func(ids []string) bool {
// Check if all expected IDs are present (order doesn't matter since we dedup via map)
foundCount := 0
for _, eid := range expectedIDs {
for _, id := range ids {
if id == eid {
foundCount++
break
}
}
}
return foundCount == len(expectedIDs) && len(ids) == len(expectedIDs)
})).Return([]domain.Tenant{
{ID: "t-direct-1", Name: "Direct Tenant"},
{ID: "t-inherited-1", Name: "Inherited Tenant 1"},
{ID: "t-inherited-2", Name: "Inherited Tenant 2"},
}, nil)
// Execute
tenants, err := svc.ListManageableTenants(ctx, userID)
// Verify
assert.NoError(t, err)
assert.Len(t, tenants, 3)
mockKeto.AssertExpectations(t)
mockRepo.AssertExpectations(t)
}

View File

@@ -1,22 +0,0 @@
package utils
import (
"os"
"strings"
)
// GetEnv retrieves the value of the environment variable named by the key.
// It returns the value if it exists, otherwise it returns the fallback value.
// It automatically strips surrounding double quotes from the value.
func GetEnv(key, fallback string) string {
v := os.Getenv(key)
if v == "" {
return fallback
}
// Strip surrounding double quotes if present
v = strings.TrimSpace(v)
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
return v[1 : len(v)-1]
}
return v
}