From 1548e60361e3e590dcad1dc824b410338447f27f Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 11 Feb 2026 10:19:47 +0900 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9(Tenant=20Group)=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20#239?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 2 + .../src/components/layout/AppLayout.tsx | 25 +++ .../routes/TenantGroupListPage.tsx | 172 ++++++++++++++++++ adminfront/src/lib/adminApi.ts | 71 ++++++++ backend/cmd/server/main.go | 12 ++ backend/internal/bootstrap/bootstrap.go | 1 + backend/internal/bootstrap/keto_sync.go | 12 ++ backend/internal/domain/tenant.go | 10 +- backend/internal/domain/tenant_group.go | 32 ++++ backend/internal/handler/auth_handler.go | 43 +++-- .../internal/handler/tenant_group_handler.go | 139 ++++++++++++++ .../repository/tenant_group_repository.go | 65 +++++++ .../internal/service/tenant_group_service.go | 94 ++++++++++ docker/ory/kratos/kratos.yml | 2 + 14 files changed, 659 insertions(+), 21 deletions(-) create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupListPage.tsx create mode 100644 backend/internal/domain/tenant_group.go create mode 100644 backend/internal/handler/tenant_group_handler.go create mode 100644 backend/internal/repository/tenant_group_repository.go create mode 100644 backend/internal/service/tenant_group_service.go diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 1c994bed..8cd13cf1 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,6 +6,7 @@ import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthPage from "../features/auth/AuthPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; +import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; @@ -30,6 +31,7 @@ export const router = createBrowserRouter( { path: "users/:id", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, + { path: "tenant-groups", element: }, { path: "tenants/:tenantId", element: , diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index c9ece4f8..22dc3735 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -1,4 +1,5 @@ import { +<<<<<<< HEAD BadgeCheck, Building2, Key, @@ -9,6 +10,19 @@ import { ShieldHalf, Sun, Users, +======= + BadgeCheck, + Building2, + Key, + KeyRound, + LayoutDashboard, + LayoutGrid, + Moon, + NotebookTabs, + ShieldHalf, + Sun, + Users, +>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239) } from "lucide-react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; @@ -16,6 +30,7 @@ import { t } from "../../lib/i18n"; import RoleSwitcher from "./RoleSwitcher"; const navItems = [ +<<<<<<< HEAD { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, { label: "ui.admin.nav.tenant_dashboard", @@ -27,6 +42,16 @@ const navItems = [ { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, +======= + { label: "Overview", to: "/", icon: LayoutDashboard }, + { label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf }, + { label: "Tenant Groups", to: "/tenant-groups", icon: LayoutGrid }, + { label: "Tenants", to: "/tenants", icon: Building2 }, + { label: "Users", to: "/users", icon: Users }, + { label: "API Keys (M2M)", to: "/api-keys", icon: Key }, + { label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs }, + { label: "Auth Guard", to: "/auth", icon: KeyRound }, +>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239) ]; function AppLayout() { diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupListPage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupListPage.tsx new file mode 100644 index 00000000..91156bf6 --- /dev/null +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupListPage.tsx @@ -0,0 +1,172 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { Pencil, Plus, RefreshCw, Trash2, LayoutGrid } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { deleteTenantGroup, fetchTenantGroups } from "../../../lib/adminApi"; + +function TenantGroupListPage() { + const navigate = useNavigate(); + const query = useQuery({ + queryKey: ["tenant-groups", { limit: 50, offset: 0 }], + queryFn: () => fetchTenantGroups(50, 0), + }); + + const deleteMutation = useMutation({ + mutationFn: (groupId: string) => deleteTenantGroup(groupId), + onSuccess: () => { + query.refetch(); + }, + }); + + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + const fallbackError = + !errorMsg && query.isError ? "테넌트 그룹 목록 조회에 실패했습니다." : null; + + const items = query.data?.items ?? []; + + const handleDelete = (groupId: string, groupName: string) => { + if (!window.confirm(`테넌트 그룹 "${groupName}"를 삭제할까요?`)) { + return; + } + deleteMutation.mutate(groupId); + }; + + return ( +
+
+
+
+ Tenants + / + Groups +
+

테넌트 그룹 목록

+

+ 여러 테넌트를 하나의 그룹으로 묶어 관리합니다. +

+
+
+ + +
+
+ + + +
+ + + Tenant Group Registry + + + 총 {query.data?.total ?? 0}개 그룹 + +
+ Super Admin only +
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + NAME + SLUG + TENANTS + CREATED + ACTIONS + + + + {query.isLoading && ( + + 로딩 중... + + )} + {!query.isLoading && items.length === 0 && ( + + + 아직 등록된 테넌트 그룹이 없습니다. + + + )} + {items.map((group) => ( + + {group.name} + {group.slug} + + + {group.tenants?.length ?? 0}개 + + + + {group.createdAt + ? new Date(group.createdAt).toLocaleDateString("ko-KR") + : "-"} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +} + +export default TenantGroupListPage; diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index cdd47242..4f2ea618 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -139,6 +139,7 @@ export async function approveTenant(tenantId: string) { return data; } +<<<<<<< HEAD // Group Management export type GroupMember = { id: string; @@ -164,21 +165,66 @@ export type GroupCreateRequest = { export async function fetchGroups(tenantId: string) { const { data } = await apiClient.get( `/v1/admin/tenants/${tenantId}/groups`, +======= +// Tenant Group Management +export type TenantGroupSummary = { + id: string; + name: string; + slug: string; + description: string; + tenants?: TenantSummary[]; + config?: Record; + createdAt: string; + updatedAt: string; +}; + +export type TenantGroupListResponse = { + items: TenantGroupSummary[]; + total: number; + limit: number; + offset: number; +}; + +export async function fetchTenantGroups(limit = 50, offset = 0) { + const { data } = await apiClient.get( + "/v1/admin/tenant-groups", + { + params: { limit, offset }, + }, +>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239) ); return data; } +<<<<<<< HEAD export async function createGroup( tenantId: string, payload: GroupCreateRequest, ) { const { data } = await apiClient.post( `/v1/admin/tenants/${tenantId}/groups`, +======= +export async function fetchTenantGroup(id: string) { + const { data } = await apiClient.get( + `/v1/admin/tenant-groups/${id}`, + ); + return data; +} + +export async function createTenantGroup(payload: { + name: string; + slug: string; + description?: string; +}) { + const { data } = await apiClient.post( + "/v1/admin/tenant-groups", +>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239) payload, ); return data; } +<<<<<<< HEAD export async function deleteGroup(groupId: string) { await apiClient.delete(`/v1/admin/groups/${groupId}`); } @@ -189,6 +235,31 @@ export async function addGroupMember(groupId: string, userId: string) { export async function removeGroupMember(groupId: string, userId: string) { await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`); +======= +export async function updateTenantGroup( + id: string, + payload: { name: string; description?: string }, +) { + const { data } = await apiClient.put( + `/v1/admin/tenant-groups/${id}`, + payload, + ); + return data; +} + +export async function deleteTenantGroup(id: string) { + await apiClient.delete(`/v1/admin/tenant-groups/${id}`); +} + +export async function addTenantToGroup(groupId: string, tenantId: string) { + await apiClient.post(`/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`); +} + +export async function removeTenantFromGroup(groupId: string, tenantId: string) { + await apiClient.delete( + `/v1/admin/tenant-groups/${groupId}/tenants/${tenantId}`, + ); +>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239) } // API Key Management (M2M) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 301cab51..bf137078 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -245,7 +245,9 @@ func main() { // 2. Initialize Handlers tenantRepo := repository.NewTenantRepository(db) + tenantGroupRepo := repository.NewTenantGroupRepository(db) tenantService := service.NewTenantService(tenantRepo) + tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService) tenantService.SetKetoService(ketoService) // Keto 주입 userRepo := repository.NewUserRepository(db) // relyingPartyRepo removed as SSOT is now Hydra+Keto @@ -259,6 +261,7 @@ func main() { adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo) tenantHandler := handler.NewTenantHandler(db, tenantService) + tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() @@ -565,6 +568,15 @@ func main() { admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant) + // 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) + // Relying Party Management (Global List) admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll) diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index fcd15586..f36b841b 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -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{}, diff --git a/backend/internal/bootstrap/keto_sync.go b/backend/internal/bootstrap/keto_sync.go index 676451ce..80e7badf 100644 --- a/backend/internal/bootstrap/keto_sync.go +++ b/backend/internal/bootstrap/keto_sync.go @@ -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 diff --git a/backend/internal/domain/tenant.go b/backend/internal/domain/tenant.go index 2383c840..2f2ac485 100644 --- a/backend/internal/domain/tenant.go +++ b/backend/internal/domain/tenant.go @@ -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"` diff --git a/backend/internal/domain/tenant_group.go b/backend/internal/domain/tenant_group.go new file mode 100644 index 00000000..db4485a7 --- /dev/null +++ b/backend/internal/domain/tenant_group.go @@ -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 +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 5bc2dc16..3e2916e6 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -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 { diff --git a/backend/internal/handler/tenant_group_handler.go b/backend/internal/handler/tenant_group_handler.go new file mode 100644 index 00000000..bcf4568c --- /dev/null +++ b/backend/internal/handler/tenant_group_handler.go @@ -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), + } +} diff --git a/backend/internal/repository/tenant_group_repository.go b/backend/internal/repository/tenant_group_repository.go new file mode 100644 index 00000000..1f22e6b4 --- /dev/null +++ b/backend/internal/repository/tenant_group_repository.go @@ -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 +} diff --git a/backend/internal/service/tenant_group_service.go b/backend/internal/service/tenant_group_service.go new file mode 100644 index 00000000..d295111a --- /dev/null +++ b/backend/internal/service/tenant_group_service.go @@ -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 +} diff --git a/docker/ory/kratos/kratos.yml b/docker/ory/kratos/kratos.yml index 9775e2bb..8e61c65b 100644 --- a/docker/ory/kratos/kratos.yml +++ b/docker/ory/kratos/kratos.yml @@ -20,6 +20,8 @@ selfservice: - https://sso.hmac.kr/ - https://app.hmac.kr - https://app.hmac.kr/ + - https://ssologin.hmac.kr + - https://ssologin.hmac.kr/ methods: password: From 4ef7ab78e24fda2b0a0e9b39865e19a1bfa8cc08 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 11 Feb 2026 10:47:44 +0900 Subject: [PATCH 02/28] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EA=B4=80=EB=A6=AC=20UI=20=EB=B0=8F=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=A9=A4=EB=B2=84=EC=8B=AD=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20#239?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 13 ++ .../routes/TenantGroupCreatePage.tsx | 142 +++++++++++++ .../routes/TenantGroupDetailPage.tsx | 77 +++++++ .../routes/TenantGroupProfileTab.tsx | 97 +++++++++ .../routes/TenantGroupTenantsTab.tsx | 200 ++++++++++++++++++ .../tenants/routes/TenantProfilePage.tsx | 27 +++ adminfront/src/lib/adminApi.ts | 61 +++--- backend/cmd/server/main.go | 2 +- backend/internal/handler/tenant_handler.go | 81 ++++--- 9 files changed, 644 insertions(+), 56 deletions(-) create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupProfileTab.tsx create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupTenantsTab.tsx diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 8cd13cf1..96bde96f 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,7 +6,11 @@ import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthPage from "../features/auth/AuthPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; +import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage"; +import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage"; import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage"; +import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab"; +import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; @@ -32,6 +36,15 @@ export const router = createBrowserRouter( { path: "tenants", element: }, { path: "tenants/new", element: }, { path: "tenant-groups", element: }, + { path: "tenant-groups/new", element: }, + { + path: "tenant-groups/:id", + element: , + children: [ + { index: true, element: }, + { path: "tenants", element: }, + ], + }, { path: "tenants/:tenantId", element: , diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx new file mode 100644 index 00000000..ccbd63e5 --- /dev/null +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx @@ -0,0 +1,142 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { LayoutGrid, Sparkles } from "lucide-react"; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { Textarea } from "../../../components/ui/textarea"; +import { createTenantGroup } from "../../../lib/adminApi"; + +function TenantGroupCreatePage() { + const navigate = useNavigate(); + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + + const mutation = useMutation({ + mutationFn: () => + createTenantGroup({ + name, + slug: slug || name.toLowerCase().replace(/ /g, "-"), + description: description || undefined, + }), + onSuccess: () => { + navigate("/tenant-groups"); + }, + }); + + const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + + return ( +
+
+
+ Tenants + / + Groups + / + Create +
+
+
+

테넌트 그룹 추가

+

+ 여러 테넌트를 논리적으로 묶어 관리하기 위한 그룹을 생성합니다. +

+
+ Super Admin only +
+
+ + + + + + Group Profile + + + 그룹 이름과 식별자(Slug)를 입력합니다. + + + +
+ + setName(e.target.value)} + placeholder="예: 바론소프트웨어 통합그룹" + /> +
+
+ + setSlug(e.target.value)} + placeholder="baron-group" + /> +

+ URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로 자동 생성됩니다. +

+
+
+ +