forked from baron/baron-sso
feat: 테넌트 그룹(Tenant Group) 기능 구현 #239
This commit is contained in:
@@ -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: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "tenant-groups", element: <TenantGroupListPage /> },
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>Tenants</span>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">Groups</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-semibold">테넌트 그룹 목록</h2>
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
여러 테넌트를 하나의 그룹으로 묶어 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link to="/tenant-groups/new">
|
||||
<Plus size={16} />
|
||||
그룹 추가
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutGrid size={20} className="text-primary" />
|
||||
Tenant Group Registry
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
총 {query.data?.total ?? 0}개 그룹
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="muted">Super Admin only</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>NAME</TableHead>
|
||||
<TableHead>SLUG</TableHead>
|
||||
<TableHead>TENANTS</TableHead>
|
||||
<TableHead>CREATED</TableHead>
|
||||
<TableHead className="text-right">ACTIONS</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>로딩 중...</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
아직 등록된 테넌트 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-semibold">{group.name}</TableCell>
|
||||
<TableCell>{group.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{group.tenants?.length ?? 0}개
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{group.createdAt
|
||||
? new Date(group.createdAt).toLocaleDateString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenant-groups/${group.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
관리
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(group.id, group.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantGroupListPage;
|
||||
@@ -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<GroupSummary[]>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
=======
|
||||
// Tenant Group Management
|
||||
export type TenantGroupSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
tenants?: TenantSummary[];
|
||||
config?: Record<string, any>;
|
||||
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<TenantGroupListResponse>(
|
||||
"/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<GroupSummary>(
|
||||
`/v1/admin/tenants/${tenantId}/groups`,
|
||||
=======
|
||||
export async function fetchTenantGroup(id: string) {
|
||||
const { data } = await apiClient.get<TenantGroupSummary>(
|
||||
`/v1/admin/tenant-groups/${id}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTenantGroup(payload: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const { data } = await apiClient.post<TenantGroupSummary>(
|
||||
"/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<TenantGroupSummary>(
|
||||
`/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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user