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 AuthPage from "../features/auth/AuthPage";
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
|
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
|
||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
@@ -30,6 +31,7 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||||
|
{ path: "tenant-groups", element: <TenantGroupListPage /> },
|
||||||
{
|
{
|
||||||
path: "tenants/:tenantId",
|
path: "tenants/:tenantId",
|
||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
<<<<<<< HEAD
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Key,
|
Key,
|
||||||
@@ -9,6 +10,19 @@ import {
|
|||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sun,
|
Sun,
|
||||||
Users,
|
Users,
|
||||||
|
=======
|
||||||
|
BadgeCheck,
|
||||||
|
Building2,
|
||||||
|
Key,
|
||||||
|
KeyRound,
|
||||||
|
LayoutDashboard,
|
||||||
|
LayoutGrid,
|
||||||
|
Moon,
|
||||||
|
NotebookTabs,
|
||||||
|
ShieldHalf,
|
||||||
|
Sun,
|
||||||
|
Users,
|
||||||
|
>>>>>>> d7d2e16 (feat: 테넌트 그룹(Tenant Group) 기능 구현 #239)
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
import { NavLink, Outlet } from "react-router-dom";
|
||||||
@@ -16,6 +30,7 @@ import { t } from "../../lib/i18n";
|
|||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
<<<<<<< HEAD
|
||||||
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.tenant_dashboard",
|
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.api_keys", to: "/api-keys", icon: Key },
|
||||||
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
|
||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
{ 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() {
|
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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
// Group Management
|
// Group Management
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -164,21 +165,66 @@ export type GroupCreateRequest = {
|
|||||||
export async function fetchGroups(tenantId: string) {
|
export async function fetchGroups(tenantId: string) {
|
||||||
const { data } = await apiClient.get<GroupSummary[]>(
|
const { data } = await apiClient.get<GroupSummary[]>(
|
||||||
`/v1/admin/tenants/${tenantId}/groups`,
|
`/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;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
export async function createGroup(
|
export async function createGroup(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
payload: GroupCreateRequest,
|
payload: GroupCreateRequest,
|
||||||
) {
|
) {
|
||||||
const { data } = await apiClient.post<GroupSummary>(
|
const { data } = await apiClient.post<GroupSummary>(
|
||||||
`/v1/admin/tenants/${tenantId}/groups`,
|
`/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,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
export async function deleteGroup(groupId: string) {
|
export async function deleteGroup(groupId: string) {
|
||||||
await apiClient.delete(`/v1/admin/groups/${groupId}`);
|
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) {
|
export async function removeGroupMember(groupId: string, userId: string) {
|
||||||
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
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)
|
// API Key Management (M2M)
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
|
tenantGroupRepo := repository.NewTenantGroupRepository(db)
|
||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
|
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
||||||
@@ -259,6 +261,7 @@ func main() {
|
|||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService)
|
||||||
|
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
oryAdminProvider := service.NewOryProvider()
|
oryAdminProvider := service.NewOryProvider()
|
||||||
@@ -565,6 +568,15 @@ func main() {
|
|||||||
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
|
||||||
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
|
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)
|
// Relying Party Management (Global List)
|
||||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func migrateSchemas(db *gorm.DB) error {
|
|||||||
slog.Info("[Bootstrap] Migrating database schemas...")
|
slog.Info("[Bootstrap] Migrating database schemas...")
|
||||||
// Add all domain models here
|
// Add all domain models here
|
||||||
return db.AutoMigrate(
|
return db.AutoMigrate(
|
||||||
|
&domain.TenantGroup{},
|
||||||
&domain.Tenant{},
|
&domain.Tenant{},
|
||||||
&domain.TenantDomain{},
|
&domain.TenantDomain{},
|
||||||
&domain.User{},
|
&domain.User{},
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
|
|||||||
if t.ParentID != nil {
|
if t.ParentID != nil {
|
||||||
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
|
_ = 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
|
// 2. Sync All Users
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ const (
|
|||||||
|
|
||||||
// Tenant represents a tenant model stored in PostgreSQL.
|
// Tenant represents a tenant model stored in PostgreSQL.
|
||||||
type Tenant struct {
|
type Tenant struct {
|
||||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||||
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
|
||||||
Name string `gorm:"not null" json:"name"`
|
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
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"`
|
Description string `json:"description"`
|
||||||
Status string `gorm:"default:'pending'" json:"status"`
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
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 {
|
func GenerateUserCode() string {
|
||||||
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
|
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))],
|
||||||
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"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Identity provider unavailable"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Changed] 토큰 길이를 사용자의 요청에 맞춰 6글자(3바이트)로, pendingRef를 8글자(4바이트)로 조정
|
|
||||||
userCode := GenerateUserCode()
|
userCode := GenerateUserCode()
|
||||||
token := GenerateSecureToken(3)
|
token := GenerateSecureToken(3)
|
||||||
pendingRef := GenerateSecureToken(3)
|
pendingRef := GenerateSecureToken(3)
|
||||||
|
|
||||||
slog.Info("[Enchanted] Initiating enchanted link", "loginID", loginID, "token", token, "pendingRef", pendingRef)
|
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
|
// Store in Redis
|
||||||
sessionData, _ := json.Marshal(map[string]string{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
"status": statusPending,
|
"status": statusPending,
|
||||||
@@ -1018,12 +1026,13 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Send SMS
|
// Send SMS
|
||||||
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
phone := sanitizePhoneForSms(loginID)
|
||||||
|
content := fmt.Sprintf("[Baron 로그인] 로그인 링크: %s | 간편 코드: %s", link, userCode)
|
||||||
if drySend {
|
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 {
|
} else {
|
||||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "to", phone)
|
||||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
if err := h.SmsService.SendSms(phone, content); err != nil {
|
||||||
slog.Error("[Enchanted] SMS Failed", "error", err)
|
slog.Error("[Enchanted] SMS Failed", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
||||||
}
|
}
|
||||||
@@ -2066,6 +2075,16 @@ type kratosCourierRequest struct {
|
|||||||
Body string `json:"body"`
|
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 발송으로 변환합니다.
|
// HandleKratosCourierRelay - Kratos courier HTTP 요청을 받아 메일/SMS 발송으로 변환합니다.
|
||||||
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
||||||
var req kratosCourierRequest
|
var req kratosCourierRequest
|
||||||
@@ -2444,16 +2463,6 @@ func extractFirstString(data map[string]interface{}, keys ...string) string {
|
|||||||
return ""
|
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 ---
|
// --- User Profile Handlers ---
|
||||||
|
|
||||||
func (h *AuthHandler) formatPhoneForDisplay(phone string) string {
|
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://sso.hmac.kr/
|
||||||
- https://app.hmac.kr
|
- https://app.hmac.kr
|
||||||
- https://app.hmac.kr/
|
- https://app.hmac.kr/
|
||||||
|
- https://ssologin.hmac.kr
|
||||||
|
- https://ssologin.hmac.kr/
|
||||||
|
|
||||||
methods:
|
methods:
|
||||||
password:
|
password:
|
||||||
|
|||||||
Reference in New Issue
Block a user