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 (
+
+
+
+
+
+
+
+
+ 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: