forked from baron/baron-sso
feat: 테넌트 그룹(Tenant Group) 기능 구현 #239
This commit is contained in:
@@ -31,6 +31,7 @@ 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{},
|
||||
|
||||
@@ -25,6 +25,18 @@ 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,10 +17,12 @@ 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
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||
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"`
|
||||
|
||||
32
backend/internal/domain/tenant_group.go
Normal file
32
backend/internal/domain/tenant_group.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
}
|
||||
@@ -125,10 +125,11 @@ func GenerateSecureAlnumToken(length int) string {
|
||||
|
||||
func GenerateUserCode() string {
|
||||
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
return fmt.Sprintf("%c%c-%03d",
|
||||
// [Fixed] 요청하신 포맷 (영문 2자리 + 숫자 6자리, 하이픈 없음)으로 변경
|
||||
return fmt.Sprintf("%c%c%06d",
|
||||
letters[rand.Intn(len(letters))],
|
||||
letters[rand.Intn(len(letters))],
|
||||
rand.Intn(1000),
|
||||
rand.Intn(1000000),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -958,13 +959,20 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||
}
|
||||
|
||||
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
|
||||
userCode := GenerateUserCode()
|
||||
token := GenerateSecureToken(3)
|
||||
pendingRef := GenerateSecureToken(3)
|
||||
|
||||
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
||||
|
||||
// [Added] 사용자가 입력할 간편 코드를 Redis에 저장합니다. (이게 없으면 인증이 안 됩니다)
|
||||
shortCodePayload, _ := json.Marshal(shortLoginCodePayload{
|
||||
LoginID: lookupLoginID,
|
||||
Code: token,
|
||||
PendingRef: pendingRef,
|
||||
})
|
||||
h.RedisService.Set(prefixLoginCodeShort+userCode, string(shortCodePayload), defaultExpiration)
|
||||
|
||||
// Store in Redis
|
||||
sessionData, _ := json.Marshal(map[string]string{
|
||||
"status": statusPending,
|
||||
@@ -1018,12 +1026,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
}
|
||||
} else {
|
||||
// Send SMS
|
||||
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
||||
phone := sanitizePhoneForSms(loginID)
|
||||
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
|
||||
if drySend {
|
||||
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
|
||||
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", phone, "content", content)
|
||||
} else {
|
||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
|
||||
if err := h.SmsService.SendSms(phone, content); err != nil {
|
||||
slog.Error("[Enchanted] SMS Failed", "error", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
||||
}
|
||||
@@ -2066,6 +2075,16 @@ type kratosCourierRequest struct {
|
||||
Body string `json:"body"`
|
||||
}
|
||||
|
||||
// sanitizePhoneForSms - 네이버 SMS 등 국내 발송기를 위해 +82 형식을 010 형식으로 변환합니다.
|
||||
func sanitizePhoneForSms(phone string) string {
|
||||
p := strings.ReplaceAll(phone, "-", "")
|
||||
p = strings.ReplaceAll(p, " ", "")
|
||||
if strings.HasPrefix(p, "+82") {
|
||||
return "0" + p[3:]
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
||||
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
||||
var req kratosCourierRequest
|
||||
@@ -2444,16 +2463,6 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func sanitizePhoneForSms(phone string) string {
|
||||
sanitized := strings.TrimSpace(phone)
|
||||
if strings.HasPrefix(sanitized, "+82") {
|
||||
sanitized = "0" + sanitized[3:]
|
||||
}
|
||||
sanitized = strings.ReplaceAll(sanitized, "-", "")
|
||||
sanitized = strings.ReplaceAll(sanitized, " ", "")
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// --- User Profile Handlers ---
|
||||
|
||||
func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
||||
|
||||
139
backend/internal/handler/tenant_group_handler.go
Normal file
139
backend/internal/handler/tenant_group_handler.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
|
||||
func NewTenantGroupHandler(svc service.TenantGroupService) *TenantGroupHandler {
|
||||
return &TenantGroupHandler{Service: svc}
|
||||
}
|
||||
|
||||
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 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),
|
||||
}
|
||||
}
|
||||
65
backend/internal/repository/tenant_group_repository.go
Normal file
65
backend/internal/repository/tenant_group_repository.go
Normal file
@@ -0,0 +1,65 @@
|
||||
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
|
||||
}
|
||||
94
backend/internal/service/tenant_group_service.go
Normal file
94
backend/internal/service/tenant_group_service.go
Normal file
@@ -0,0 +1,94 @@
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user