1
0
forked from baron/baron-sso

Merge branch 'feature/user-group' into dev

This commit is contained in:
2026-02-13 14:22:32 +09:00
31 changed files with 2690 additions and 1009 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,9 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.1.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
@@ -28,6 +30,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-router-dom": "^6.28.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
},

View File

@@ -8,18 +8,15 @@ import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import GlobalUserGroupListPage from "../features/user-groups/routes/GlobalUserGroupListPage";
import { TenantUserGroupsTab } from "../features/user-groups/routes/TenantUserGroupsTab";
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
@@ -45,28 +42,23 @@ export const router = createBrowserRouter(
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "user-groups", element: <GlobalUserGroupListPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "tenant-groups", element: <TenantGroupListPage /> },
{ path: "tenant-groups/new", element: <TenantGroupCreatePage /> },
{
path: "tenant-groups/:id",
element: <TenantGroupDetailPage />,
children: [
{ index: true, element: <TenantGroupProfileTab /> },
{ path: "tenants", element: <TenantGroupTenantsTab /> },
{ path: "admins", element: <TenantGroupAdminsTab /> },
],
},
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> },
{ path: "user-groups", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{
path: "tenants/:tenantId/user-groups/:id",
element: <UserGroupDetailPage />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],

View File

@@ -23,7 +23,16 @@ const navItems = [
to: "/dashboard",
icon: ShieldHalf,
},
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
},
{
label: "ui.admin.nav.user_groups",
to: "/user-groups",
icon: Users,
},
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "../../lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "../../lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=top]:slide-in-from-bottom-2 data-[state=bottom]:slide-in-from-top-2",
position === "popper" &&
"data-[state=open]:slide-in-from-top-2 data-[state=bottom]:translate-y-1 data-[state=left]:-translate-x-1 data-[state=right]:translate-x-1 data-[state=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,215 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
type TenantGroupSummary,
addGroupAdmin,
fetchGroupAdmins,
fetchUsers,
removeGroupAdmin,
} from "../../../lib/adminApi";
function TenantGroupAdminsTab() {
const { group } = useOutletContext<{
group: TenantGroupSummary;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-group-admins", group.id],
queryFn: () => fetchGroupAdmins(group.id),
enabled: !!group.id,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
});
const addMutation = useMutation({
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 그룹 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 &&
usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id))
.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupAdminsTab;

View File

@@ -1,144 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { LayoutGrid, Sparkles } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenantGroup } from "../../../lib/adminApi";
function TenantGroupCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const mutation = useMutation({
mutationFn: () =>
createTenantGroup({
name,
slug: slug || name.toLowerCase().replace(/ /g, "-"),
description: description || undefined,
}),
onSuccess: () => {
navigate("/tenant-groups");
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span>Groups</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutGrid size={18} className="text-primary" />
Group Profile
</CardTitle>
<CardDescription>
(Slug) .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Group Name <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 바론소프트웨어 통합그룹"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="baron-group"
/>
<p className="text-xs text-muted-foreground">
URL이나 API에서 .
.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="그룹에 대한 설명을 입력하세요."
/>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
.
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenant-groups")}>
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
</Button>
</div>
</div>
);
}
export default TenantGroupCreatePage;

View File

@@ -1,94 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, LayoutGrid } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenantGroup } from "../../../lib/adminApi";
function TenantGroupDetailPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const groupQuery = useQuery({
queryKey: ["tenant-group", id],
queryFn: () => fetchTenantGroup(id!),
enabled: !!id,
});
const isTenantsTab = location.pathname.endsWith("/tenants");
const isAdminTab = location.pathname.endsWith("/admins");
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)]">
<Link
to="/tenant-groups"
className="inline-flex items-center gap-2 hover:text-foreground"
>
<ArrowLeft size={14} />
Groups
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutGrid size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">
{groupQuery.data?.name ?? "Loading Group..."}
</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
{groupQuery.data?.description ||
"그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b border-border">
<Link
to={`/tenant-groups/${id}`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
!isTenantsTab && !isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
<Link
to={`/tenant-groups/${id}/tenants`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isTenantsTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
({groupQuery.data?.tenants?.length ?? 0})
</Link>
<Link
to={`/tenant-groups/${id}/admins`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
</div>
<div className="mt-6">
<Outlet
context={{ group: groupQuery.data, refetch: groupQuery.refetch }}
/>
</div>
</div>
);
}
export default TenantGroupDetailPage;

View File

@@ -1,172 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { LayoutGrid, Pencil, Plus, RefreshCw, Trash2 } 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;

View File

@@ -1,104 +0,0 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import {
type TenantGroupSummary,
updateTenantGroup,
} from "../../../lib/adminApi";
function TenantGroupProfileTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
const [name, setName] = useState(group?.name ?? "");
const [description, setDescription] = useState(group?.description ?? "");
const mutation = useMutation({
mutationFn: () => updateTenantGroup(group.id, { name, description }),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
if (!group) return null;
return (
<div className="max-w-2xl space-y-6">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
. (Slug)
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> ID ( )</Label>
<Input value={group.id} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Input value={group.slug} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label htmlFor="groupName">Group Name</Label>
<Input
id="groupName"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="groupDesc">Description</Label>
<Textarea
id="groupDesc"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
<div className="flex justify-end pt-4">
<Button
onClick={() => mutation.mutate()}
disabled={
mutation.isPending ||
(name === group.name && description === group.description)
}
>
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupProfileTab;

View File

@@ -1,210 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
type TenantGroupSummary,
addTenantToGroup,
fetchTenants,
removeTenantFromGroup,
} from "../../../lib/adminApi";
function TenantGroupTenantsTab() {
const { group, refetch } = useOutletContext<{
group: TenantGroupSummary;
refetch: () => void;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
// 전체 테넌트 목록 (할당용)
const tenantsQuery = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const addMutation = useMutation({
mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
},
});
const removeMutation = useMutation({
mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
onSuccess: () => {
refetch();
queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
},
});
const handleAddTenant = (tenantId: string) => {
addMutation.mutate(tenantId);
};
const handleRemoveTenant = (tenantId: string) => {
if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
removeMutation.mutate(tenantId);
}
};
const availableTenants =
tenantsQuery.data?.items.filter(
(t) => !group.tenants?.some((gt) => gt.id === t.id),
) || [];
const filteredAvailable = availableTenants.filter(
(t) =>
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 소속 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.tenants?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{group.tenants?.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-xs">{t.slug}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 추가 가능한 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Plus size={18} className="text-primary" />
</CardTitle>
<div className="relative w-48">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-8 h-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAvailable.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{filteredAvailable.map((t) => (
<TableRow key={t.id}>
<TableCell>
<div className="font-medium">{t.name}</div>
<div className="text-[10px] text-muted-foreground">
{t.slug}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupTenantsTab;

View File

@@ -17,6 +17,7 @@ function TenantDetailPage() {
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
const isUserGroupsTab = location.pathname.includes("/user-groups");
return (
<div className="space-y-8">
@@ -74,6 +75,16 @@ function TenantDetailPage() {
>
Admins
</Link>
<Link
to={`/tenants/${tenantId}/user-groups`}
className={`px-4 py-2 text-sm font-medium ${
isUserGroupsTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
User Groups
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${

View File

@@ -0,0 +1,125 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
import { Link } 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 { fetchTenants, fetchGroups } from "../../../lib/adminApi";
import { useState } from "react";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
});
if (isTenantsLoading) return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-8">
<header className="flex items-start justify-between">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
. .
</p>
</div>
</header>
<div className="grid gap-6">
{tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} />
))}
</div>
</div>
);
}
function TenantGroupCard({ tenant }: { tenant: any }) {
const { data: groups, isLoading } = useQuery({
queryKey: ["tenant-user-groups", tenant.id],
queryFn: () => fetchGroups(tenant.id),
});
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
{tenant.name}
<Badge variant="outline" className="ml-2">{tenant.slug}</Badge>
</CardTitle>
<CardDescription>
.
</CardDescription>
</div>
<Button size="sm" variant="outline" asChild>
<Link to={`/tenants/${tenant.id}/user-groups`}>
<Plus size={16} className="mr-2" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">Loading...</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground py-4">
.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`} className="hover:underline">
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link to={`/tenants/${tenant.id}/user-groups/${group.id}`}></Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,205 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, Users } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { createGroup, deleteGroup, fetchGroups } from "../../../lib/adminApi";
export function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupDesc] = useState("");
const { data: groups, isLoading } = useQuery({
queryKey: ["tenant-user-groups", tenantId],
queryFn: () => fetchGroups(tenantId!),
enabled: !!tenantId,
});
const createMutation = useMutation({
mutationFn: () =>
createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["tenant-user-groups", tenantId],
});
setIsCreateOpen(false);
setNewGroupName("");
setNewGroupDesc("");
alert("User group created successfully");
},
onError: (error: any) => {
alert(error.message || "Failed to create user group");
},
});
const deleteMutation = useMutation({
mutationFn: (groupId: string) => deleteGroup(tenantId!, groupId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["tenant-user-groups", tenantId],
});
alert("User group deleted successfully");
},
});
if (isLoading) return <div>Loading user groups...</div>;
return (
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription>
Manage user groups within this tenant for collective permission
assignment.
</CardDescription>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus size={16} className="mr-2" />
Create Group
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create User Group</DialogTitle>
<DialogDescription>
Create a new group to manage users collectively.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Group Name</Label>
<Input
id="name"
placeholder="e.g. Developers, Project A Managers"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
placeholder="Brief description of the group"
value={newGroupDesc}
onChange={(e) => setNewGroupDesc(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{createMutation.isPending ? "Creating..." : "Create Group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Created At</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
No user groups found for this tenant.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={16} className="text-muted-foreground" />
<Link
to={`/tenants/${tenantId}/user-groups/${group.id}`}
className="hover:underline text-primary"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>
{group.createdAt
? new Date(group.createdAt).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
"Are you sure you want to delete this group?",
)
) {
deleteMutation.mutate(group.id);
}
}}
>
<Trash2 size={16} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,410 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Plus, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react";
import { Link, useParams } 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
addGroupMember,
assignGroupRole,
fetchGroup,
fetchGroupRoles,
fetchTenants,
fetchUsers,
removeGroupMember,
removeGroupRole,
} from "../../../lib/adminApi";
export function UserGroupDetailPage() {
const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
const queryClient = useQueryClient();
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [searchUser, setSearchUser] = useState("");
const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
const [selectedRelation, setSelectedRelation] = useState("view");
// Fetch specific group details
const { data: currentGroup, isLoading: isGroupLoading, error } = useQuery({
queryKey: ["user-group-detail", id],
queryFn: () => fetchGroup(tenantId!, id!),
enabled: !!id && !!tenantId,
retry: false,
});
// Fetch assigned roles
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
queryKey: ["user-group-roles", id],
queryFn: () => fetchGroupRoles(tenantId!, id!),
enabled: !!id && !!tenantId,
});
// Fetch all users for selection
const { data: userList } = useQuery({
queryKey: ["admin-users", searchUser],
queryFn: () => fetchUsers(20, 0, searchUser),
enabled: isAddMemberOpen,
});
// Fetch all tenants for role assignment
const { data: tenantList } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
enabled: isAddRoleOpen,
});
const addMemberMutation = useMutation({
mutationFn: (userId: string) => addGroupMember(tenantId!, id!, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false);
setSelectedUserId("");
alert("Member added successfully");
},
onError: (error: any) => {
alert(error.message || "Failed to add member");
},
});
const removeMemberMutation = useMutation({
mutationFn: (userId: string) => removeGroupMember(tenantId!, id!, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
alert("Member removed successfully");
},
});
const assignRoleMutation = useMutation({
mutationFn: () =>
assignGroupRole(
tenantId!,
id!,
selectedTargetTenantId,
selectedRelation,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false);
alert(`Role '${selectedRelation}' assigned successfully`);
},
onError: (error: any) => {
alert(error.message || "Failed to assign role");
},
});
const removeRoleMutation = useMutation({
mutationFn: (role: { targetTenantId: string; relation: string }) =>
removeGroupRole(tenantId!, id!, role.targetTenantId, role.relation),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
alert("Role removed successfully");
},
});
if (isGroupLoading) return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-3 text-muted-foreground">Loading group details...</span>
</div>
);
if (error || !currentGroup) return (
<div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive">Could not load group</h3>
<div className="p-4 bg-red-50 text-red-700 rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-red-100">
<p>Error: {(error as any)?.response?.data?.error || (error as any)?.message || "Not found"}</p>
<p className="mt-2 text-red-500 opacity-70">Path: /admin/tenants/{tenantId}/user-groups/{id}</p>
</div>
<p className="text-muted-foreground pt-2">The group ID might be invalid or you don't have sufficient permissions.</p>
<Button variant="outline" onClick={() => window.location.reload()}>Retry</Button>
<div className="pt-4 border-t">
<Link to={`/tenants/${tenantId}/user-groups`} className="text-primary hover:underline text-sm">
Return to Group List
</Link>
</div>
</div>
);
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)]">
<Link
to={`/tenants/${tenantId}`}
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} />
Tenant Detail
</Link>
<span>/</span>
<span className="text-foreground">User Group</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Users size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
{currentGroup.description || "No description provided."}
</p>
</div>
<div className="flex gap-2">
<Badge variant="outline">User Group</Badge>
<Badge variant="muted">Tenant: {tenantId?.split('-')[0]}...</Badge>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Members Management */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Members</CardTitle>
<CardDescription>Manage users in this group.</CardDescription>
</div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<UserPlus size={16} className="mr-2" />
Add Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
Select a user to add to this group.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Search User</Label>
<Input
placeholder="Search by email or name..."
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Select User</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger>
<SelectValue placeholder="Choose a user" />
</SelectTrigger>
<SelectContent>
{userList?.items.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddMemberOpen(false)}>
Cancel
</Button>
<Button
onClick={() => addMemberMutation.mutate(selectedUserId)}
disabled={!selectedUserId || addMemberMutation.isPending}
>
Add
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members || currentGroup.members.length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 text-muted-foreground">
No members in this group.
</TableCell>
</TableRow>
) : (
currentGroup.members.map((member) => (
<TableRow key={member.id}>
<TableCell>
<div>
<p className="font-medium">{member.name}</p>
<p className="text-xs text-muted-foreground">{member.email}</p>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeMemberMutation.mutate(member.id)}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Roles/Permissions Management (Keto Based) */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Permissions</CardTitle>
<CardDescription>Tenant roles assigned to this group.</CardDescription>
</div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Shield size={16} className="mr-2" />
Assign Role
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Tenant Role</DialogTitle>
<DialogDescription>
Members of this group will inherit this role on the target tenant.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Target Tenant</Label>
<Select value={selectedTargetTenantId} onValueChange={setSelectedTargetTenantId}>
<SelectTrigger>
<SelectValue placeholder="Select target tenant" />
</SelectTrigger>
<SelectContent>
{tenantList?.items.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.slug})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Role (Relation)</Label>
<Select value={selectedRelation} onValueChange={setSelectedRelation}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="view">View (Read-only)</SelectItem>
<SelectItem value="manage">Manage (Read/Write)</SelectItem>
<SelectItem value="admins">Admin (Full Control)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddRoleOpen(false)}>
Cancel
</Button>
<Button
onClick={() => assignRoleMutation.mutate()}
disabled={!selectedTargetTenantId || assignRoleMutation.isPending}
>
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Target Tenant</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow><TableCell colSpan={3} className="text-center">Loading...</TableCell></TableRow>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground">
No roles assigned.
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
<TableCell>
<div className="font-medium">{role.tenantName || role.tenantId}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{role.relation}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeRoleMutation.mutate({ targetTenantId: role.tenantId, relation: role.relation })}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -163,7 +163,14 @@ export type GroupCreateRequest = {
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/groups`,
`/v1/admin/tenants/${tenantId}/user-groups`,
);
return data;
}
export async function fetchGroup(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupSummary>(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`,
);
return data;
}
@@ -173,22 +180,71 @@ export async function createGroup(
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/groups`,
`/v1/admin/tenants/${tenantId}/user-groups`,
payload,
);
return data;
}
export async function deleteGroup(groupId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}`);
export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`);
}
export async function addGroupMember(groupId: string, userId: string) {
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
export async function addGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members`,
{ userId },
);
}
export async function removeGroupMember(groupId: string, userId: string) {
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
export async function removeGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members/${userId}`,
);
}
export type GroupRole = {
tenantId: string;
tenantName: string;
relation: string;
};
export async function fetchGroupRoles(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupRole[]>(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
);
return data;
}
export async function assignGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`,
{ tenantId: targetTenantId, relation },
);
}
export async function removeGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles/${targetTenantId}/${relation}`,
);
}
// API Key Management (M2M)

View File

@@ -244,10 +244,15 @@ func main() {
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
tenantService := service.NewTenantService(tenantRepo)
tenantService.SetKetoService(ketoService) // Keto 주입
userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db)
// relyingPartyRepo removed as SSOT is now Hydra+Keto
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
@@ -255,12 +260,11 @@ func main() {
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -535,6 +539,22 @@ func main() {
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
// User Group Management (Tenant Admin/Super Admin)
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)

View File

@@ -34,6 +34,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
&domain.UserGroup{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},

View File

@@ -21,6 +21,12 @@ type UserGroup struct {
Members []User `gorm:"-" json:"members,omitempty"`
}
type GroupRole struct {
TenantID string `json:"tenantId"`
TenantName string `json:"tenantName"`
Relation string `json:"relation"`
}
func (ug *UserGroup) TableName() string {
return "user_groups"
}

View File

@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
id := c.Params("id")
group, err := h.Service.Get(c.Context(), id)
if err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "group not found"})
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to get group: " + err.Error()})
}
return c.JSON(group)
}
@@ -93,3 +93,39 @@ func (h *UserGroupHandler) RemoveMember(c *fiber.Ctx) error {
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
groupID := c.Params("id")
var req struct {
TenantID string `json:"tenantId"`
Relation string `json:"relation"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
}
if err := h.Service.AssignRoleToTenant(c.Context(), groupID, req.TenantID, req.Relation); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *UserGroupHandler) ListRoles(c *fiber.Ctx) error {
groupID := c.Params("id")
roles, err := h.Service.ListRoles(c.Context(), groupID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(roles)
}
func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
groupID := c.Params("id")
tenantID := c.Params("tenantId")
relation := c.Params("relation")
if err := h.Service.RemoveRoleFromTenant(c.Context(), groupID, tenantID, relation); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.SendStatus(fiber.StatusNoContent)
}

View File

@@ -37,20 +37,38 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
}
// Get object ID from path (e.g., tenant ID)
objectID := c.Params("id")
if objectID == "" {
// Fix: For Tenant namespace, prioritize tenantId param if available
objectID := ""
if namespace == "Tenant" {
objectID = c.Params("tenantId")
}
if objectID == "" {
objectID = c.Params("id")
}
if objectID == "" {
slog.Error("RBAC Keto check failed: missing object id", "path", c.Path())
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "missing object id for permission check"})
}
slog.Info("Performing Keto permission check", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
// Set tenant_id for audit logging if namespace is Tenant
if namespace == "Tenant" {
c.Locals("tenant_id", objectID)
}
// Check with Keto
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
if err != nil || !allowed {
if err != nil {
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "permission check error"})
}
if !allowed {
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied"})
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: keto permission denied for " + namespace + ":" + objectID})
}
return c.Next()
@@ -136,7 +154,26 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
targetTenantID = c.Params("id") // common for /tenants/:id
}
if profile.TenantID == nil || *profile.TenantID != targetTenantID {
if targetTenantID == "" {
return c.Next() // No target specified, let Keto or next handler decide
}
// Check primary tenant match
if profile.TenantID != nil && *profile.TenantID == targetTenantID {
return c.Next()
}
// Check inherited manageable tenants
isAllowed := false
for _, t := range profile.ManageableTenants {
if t.ID == targetTenantID {
isAllowed = true
break
}
}
if !isAllowed {
slog.Warn("Tenant match failed", "userID", profile.ID, "targetTenantID", targetTenantID)
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"error": "forbidden: you do not have access to this tenant",
})

View File

@@ -37,7 +37,8 @@ func (r *userGroupRepository) Delete(ctx context.Context, id string) error {
func (r *userGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
var group domain.UserGroup
if err := r.db.WithContext(ctx).First(&group, "id = ?", id).Error; err != nil {
// Using Where to be more explicit and avoid issues with GORM's default primary key handling if ID is string/uuid
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&group).Error; err != nil {
return nil, err
}
return &group, nil

View File

@@ -169,7 +169,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
}
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
u, _ := url.Parse(fmt.Sprintf("%s/relation-tuples", s.writeURL))
u, _ := url.Parse(fmt.Sprintf("%s/admin/relation-tuples", s.writeURL))
q := u.Query()
q.Set("namespace", namespace)
q.Set("object", object)

View File

@@ -39,6 +39,74 @@ func (s *tenantService) GetTenant(ctx context.Context, id string) (*domain.Tenan
return s.repo.FindByID(ctx, id)
}
func (s *tenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
if err != nil {
slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
}
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
if err != nil {
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
}
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
if err != nil {
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
}
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
var inheritedTenantIDs []string
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
for _, groupID := range allMyGroups {
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
if err == nil {
for _, r := range relations {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
// view 권한도 관리 가능 목록에 포함 (필요 시)
relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
if err == nil {
for _, r := range relationsView {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
}
// 합산 및 중복 제거
allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs {
allIDsMap[id] = true
}
for _, id := range ownedGroupIDs {
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
}
for _, id := range inheritedTenantIDs {
allIDsMap[id] = true
}
allIDs := make([]string, 0, len(allIDsMap))
for id := range allIDsMap {
allIDs = append(allIDs, id)
}
if len(allIDs) == 0 {
return []domain.Tenant{}, nil
}
return s.repo.FindByIDs(ctx, allIDs)
}
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) {
// Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok {

View File

@@ -17,19 +17,34 @@ type UserGroupService interface {
// Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error
RemoveMember(ctx context.Context, groupID, userID string) error
// Permission Management
ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error)
AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error
RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error
}
type userGroupService struct {
repo repository.UserGroupRepository
userRepo repository.UserRepository
tenantRepo repository.TenantRepository
ketoService KetoService
kratos *KratosAdminService
}
func NewUserGroupService(repo repository.UserGroupRepository, userRepo repository.UserRepository, keto KetoService) UserGroupService {
func NewUserGroupService(
repo repository.UserGroupRepository,
userRepo repository.UserRepository,
tenantRepo repository.TenantRepository,
keto KetoService,
kratos *KratosAdminService,
) UserGroupService {
return &userGroupService{
repo: repo,
userRepo: userRepo,
tenantRepo: tenantRepo,
ketoService: keto,
kratos: kratos,
}
}
@@ -66,36 +81,75 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
if err != nil {
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
// Return group without members rather than failing?
// But if we fail here, we might hide partial failure. Let's log and proceed or return error?
// For now, let's proceed with empty members to avoid blocking UI if keto is down?
// No, SSOT is Keto. If Keto is down, we can't show members.
// Returning error might be safer.
return nil, err
}
var userIDs []string
for _, t := range tuples {
// SubjectID is like "User:uuid"
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
sid := t.SubjectID
if len(sid) > 5 && sid[:5] == "User:" {
userIDs = append(userIDs, sid[5:])
} else {
userIDs = append(userIDs, sid)
}
}
if len(userIDs) > 0 {
// 1. Try to find in local DB
members, err := s.userRepo.FindByIDs(ctx, userIDs)
if err != nil {
slog.Error("Failed to fetch member details from db", "error", err)
return nil, err
}
group.Members = members
// 2. Map existing DB members
memberMap := make(map[string]domain.User)
for _, m := range members {
memberMap[m.ID] = m
}
// 3. For IDs not in DB, fetch from Kratos
var finalMembers []domain.User
for _, uid := range userIDs {
if m, ok := memberMap[uid]; ok {
finalMembers = append(finalMembers, m)
} else if s.kratos != nil {
// Fallback to Kratos
identity, err := s.kratos.GetIdentity(ctx, uid)
if err == nil && identity != nil {
name, _ := identity.Traits["name"].(string)
email, _ := identity.Traits["email"].(string)
finalMembers = append(finalMembers, domain.User{
ID: uid,
Name: name,
Email: email,
})
}
}
}
group.Members = finalMembers
} else {
group.Members = []domain.User{}
}
return group, nil
}
func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
return s.repo.ListByTenantID(ctx, tenantID)
groups, err := s.repo.ListByTenantID(ctx, tenantID)
if err != nil {
return nil, err
}
// For each group, fetch member count from Keto
for i := range groups {
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "")
if err == nil {
// Create dummy members just to carry the count for the JSON response
groups[i].Members = make([]domain.User, len(tuples))
}
}
return groups, nil
}
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
@@ -119,3 +173,63 @@ func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID str
return nil
}
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
// Query: namespace=Tenant, subject=UserGroup:groupID#members
subject := "UserGroup:" + groupID + "#members"
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
if err != nil {
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
return nil, err
}
var roles []domain.GroupRole
tenantIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
tenantIDs = append(tenantIDs, t.Object)
}
if len(tenantIDs) > 0 {
tenantList, err := s.tenantRepo.FindByIDs(ctx, tenantIDs)
if err != nil {
slog.Error("Failed to fetch tenant details for roles", "error", err)
}
tenantMap := make(map[string]string)
for _, t := range tenantList {
tenantMap[t.ID] = t.Name
}
for _, t := range tuples {
roles = append(roles, domain.GroupRole{
TenantID: t.Object,
TenantName: tenantMap[t.Object],
Relation: t.Relation,
})
}
}
return roles, nil
}
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
// This means all members of the group have the relation on the tenant.
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
}
return nil
}
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
subject := "UserGroup:" + groupID + "#members"
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject)
if err != nil {
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation)
return err
}
return nil
}

View File

@@ -22,8 +22,8 @@ class UserGroup implements Namespace {
class Tenant implements Namespace {
related: {
admins: User[]
members: User[]
admins: (User | SubjectSet<UserGroup, "members">)[]
members: (User | SubjectSet<UserGroup, "members">)[]
parent: Tenant[]
parent_group: TenantGroup[]
}

View File

@@ -0,0 +1,143 @@
# Ory Keto ReBAC 정책 및 관계 튜플 가이드
이 문서는 Baron SSO의 통합 권한 정책을 Ory Keto(Zanzibar 스타일 권한 엔진)에서 구현하기 위한 네임스페이스 설계와 관계 튜플(Relationship Tuples) 예제를 정의합니다.
## 0. 권한 흐름 다이어그램 (Permission Flow)
```mermaid
graph LR
%% Subjects
U[User: 사용자]
%% Intermediate Groups
subgraph Groups [유저 그룹 / 테넌트 관리 주체]
UG_O[UserGroup: Owners / 그룹장]
UG_M[UserGroup: Members / 멤버]
end
%% Resources
subgraph Resources [테넌트 및 하위 자원]
T[Tenant: 부모 테넌트]
CT[Tenant: 자식 테넌트]
RP[RelyingParty: 앱/클라이언트]
end
%% Relations (Solid = Direct, Dash = Inherited)
U -- "owner of" --> UG_O
U -- "member of" --> UG_M
UG_O -- "becomes Admin of" --> T
UG_M -- "gets View/Manage of" --> T
T -- "controls" --> CT
T -- "owns" --> RP
%% Effective Permissions (Dash)
U -. "inherits Admin" .-> T
U -. "inherits Access" .-> CT
U -. "can manage" .-> RP
%% Styles
style UG_O fill:#ff9,stroke:#333
style T fill:#dfd,stroke:#333
style RP fill:#bbf,stroke:#333
```
## 1. 네임스페이스 정의 (Namespaces)
| 네임스페이스 | 역할 | 비고 |
| :--- | :--- | :--- |
| `Tenant` | 격리된 자원 공간 (Workspace) | 모든 유저 그룹은 테넌트의 한 종류임 |
| `UserGroup` | 사용자의 집합 (Specialized Tenant) | `Tenant` 네임스페이스를 상속하거나 호환됨 |
| `RelyingParty` | OAuth2 클라이언트 앱 | 특정 테넌트에 소속됨 |
| `System` | 시스템 전역 권한 | Super Admin 등을 관리 |
## 2. 핵심 정책의 Keto 구현
### 2.1 "모든 유저 그룹은 테넌트이다"
유저 그룹이 생성될 때, 해당 ID는 `Tenant` 네임스페이스에도 동시에 존재하며 동일한 상속 로직을 공유합니다.
### 2.2 "그룹장은 해당 테넌트의 어드민이다"
그룹장(Leader/Owner) 관계가 형성되면, Keto의 **Subject Set** 기능을 통해 테넌트의 `admins` 권한으로 자동 전파됩니다.
---
## 3. Keto 관계 튜플 예제 (Relationship Tuples)
Keto에 저장되는 데이터 포맷 예시입니다: `namespace:object#relation@subject`
### 3.1 사용자-그룹 관계 (Identity to Group)
| 설명 | Keto 튜플 예제 |
| :--- | :--- |
| **그룹 멤버 추가** | `UserGroup:dev-team#members@User:uuid-123` |
| **그룹장 임명 (Leader)** | `UserGroup:dev-team#owners@User:uuid-leader` |
### 3.2 그룹-테넌트 권한 전파 (Group to Resource)
| 설명 | Keto 튜플 예제 |
| :--- | :--- |
| **그룹장 -> 어드민 자동 상속** | `Tenant:dev-team#admins@UserGroup:dev-team#owners` |
| **그룹 -> 하위 테넌트 관리 권한** | `Tenant:project-alpha#manage@UserGroup:dev-team#members` |
| **그룹 -> 하위 테넌트 조회 권한** | `Tenant:project-beta#view@UserGroup:dev-team#members` |
### 3.3 테넌트-자원 소속 관계 (Hierarchy)
| 설명 | Keto 튜플 예제 |
| :--- | :--- |
| **자식 테넌트 설정** | `Tenant:child-dept#parents@Tenant:parent-corp` |
| **앱(RP) 소속 테넌트 지정** | `RelyingParty:auth-app#parents@Tenant:hanmac-family` |
---
## 4. 권한 검증 로직 (Permission Check Logic)
시스템이 권한을 확인할 때(Check API) 사용하는 로직입니다.
### 4.1 그룹장의 테넌트 관리 권한 확인
사용자 `uuid-leader``dev-team` 테넌트를 관리할 수 있는지 확인:
```bash
# 요청 (Check)
GET /relation-tuples/check?namespace=Tenant&object=dev-team&relation=manage&subject_id=uuid-leader
# Keto 내부 추론 경로
1. Tenant:dev-team#manage 권한은 Tenant:dev-team#admins에게 있음.
2. Tenant:dev-team#admins는 UserGroup:dev-team#owners를 포함함.
3. UserGroup:dev-team#owners에 User:uuid-leader가 존재함.
=> 결과: ALLOW (True)
```
### 4.2 그룹 멤버의 하위 자원(RP) 접근 확인
사용자 `uuid-123``auth-app` 설정을 볼 수 있는지 확인:
```bash
# 요청 (Check)
GET /relation-tuples/check?namespace=RelyingParty&object=auth-app&relation=view&subject_id=uuid-123
# Keto 내부 추론 경로
1. RelyingParty:auth-app#view 권한은 부모인 Tenant:hanmac-family#view에 의존함.
2. Tenant:hanmac-family#view 권한은 UserGroup:dev-team#members에게 부여됨.
3. UserGroup:dev-team#members에 User:uuid-123이 존재함.
=> 결과: ALLOW (True)
```
## 5. 정책 요약 코드 (Namespace Config - DSL Style)
이 정책을 지원하기 위한 Keto 네임스페이스 설정 스키마 개념도입니다.
```typescript
class Tenant implements Namespace {
related: {
admins: (User | UserGroup#owners)[]
members: (User | UserGroup#members)[]
parents: Tenant[]
}
permits: {
view: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) ||
this.related.admins.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.view(ctx)),
manage: (ctx: Context): boolean =>
this.related.admins.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage(ctx))
}
}
```

View File

@@ -18,9 +18,9 @@
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
### 2.3 RequireTenantMatch
- 테넌트 관리자 권한을 가진 사용자가 **자신의 테넌트**에만 접근하도록 보장합니다.
- Super Admin은 즉시 통과합니다.
- API Key 인증은 우회합니다.
- 사용자가 요청한 테넌트에 대한 관리 자격이 있는지 검증합니다.
- **상속 권한 인정:** 사용자의 기본 테넌트뿐만 아니라, 유저 그룹 멤버십이나 그룹장 직책을 통해 **상속받은 모든 테넌트**를 대상으로 합니다.
- Super Admin 및 유효한 API Key 요청은 통과합니다.
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
@@ -32,7 +32,7 @@
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
3) **테넌트 범위 제어의 명확성**
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다.
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. 유저 그룹 도입 이후에는 "상속받은 모든 관리 대상 테넌트"로 범위가 확장됩니다.
4) **성능 및 안정성**
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
@@ -48,16 +48,19 @@
### 4.2 권한/정책 SoT
- **1순위: Keto(ReBAC) 관계 튜플**
- 리소스 접근 권한의 최종 판단 기준
- 리소스 접근 권한의 최종 판단 기준.
- **유저 그룹 상속:** 사용자가 속한 유저 그룹에 부여된 권한은 Keto를 통해 실시간으로 상속됩니다.
- **그룹장-어드민 연동:** 유저 그룹의 장(Leader)은 해당 그룹(테넌트)의 어드민 권한을 자동으로 가집니다.
- **2순위: RBAC(Role)**
- 전역/상위 정책의 단축 규칙
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선
- 전역/상위 정책의 단축 규칙.
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선.
### 4.3 테넌트 컨텍스트 SoT
- **1순위: 서버 측 프로필(예: UserProfile.tenantId)**
- **1순위: 서버 측 프로필 및 상속된 권한 (ManageableTenants)**
- 사용자의 기본 `tenantId`뿐만 아니라, 유저 그룹을 통해 **상속받은 관리 가능 테넌트 목록** 전체를 기준으로 판단합니다.
- **2순위: 요청 헤더(X-Tenant-ID)**
- 헤더는 "요청 의도"를 나타내지만, 항상 서버 프로필과 일치해야 함
- 불일치 시 차단
- 헤더는 "요청 의도"를 나타내며, `ManageableTenants` 목록에 포함된 ID여야 합니다.
- 불일치 시 차단.
### 4.4 OIDC/RP 정보 SoT
- **1순위: Hydra Client/Consent 데이터**

View File

@@ -0,0 +1,68 @@
# 유저 그룹 및 테넌트 통합 권한 정책 (Integrated Policy)
이 문서는 Baron SSO의 테넌트(Tenant)와 유저 그룹(User Group) 간의 관계 및 권한 상속에 관한 공식 정책을 정의합니다.
## 1. 기본 원칙 (Core Axioms)
### 1.1 유저 그룹의 테넌트성 (User Group as a Tenant)
- **모든 테넌트가 유저 그룹은 아니지만, 모든 유저 그룹은 반드시 테넌트의 속성을 가집니다.**
- 유저 그룹은 "사용자들의 집합"인 동시에, 그 자체가 권한을 담고 다른 자원을 소유할 수 있는 **격리된 공간(Tenant)**으로 취급됩니다.
### 1.2 권한 상속 로직의 단일화 (Unified Inheritance)
- 테넌트 간의 상속(Parent-Child Tenant)과 유저 그룹의 권한 전파(Group-Member)는 **기술적으로 동일한 ReBAC 로직**을 사용합니다.
- `UserGroup:members` 관계는 `Tenant:members`와 동일한 우선순위를 가지며, 시스템은 이를 구분 없이 하나의 상속 트리로 처리합니다.
### 1.3 그룹장-어드민 연동 (Leader-Admin Mapping)
- 특정 유저 그룹에 명시적으로 **'그룹장(Group Leader)'**을 지정하면, 시스템은 해당 사용자를 해당 유저 그룹(테넌트)의 **'테넌트 어드민(Tenant Admin)'**으로 자동 격상합니다.
- 그룹장은 해당 그룹이 소유한 모든 하위 테넌트 및 리소스에 대해 완전한 제어권을 가집니다.
## 2. 권한 흐름도 (Mermaid)
```mermaid
graph TD
%% Roles
Leader[Group Leader / 그룹장]
Member[Group Member / 멤버]
%% Entities (Polymorphic)
subgraph UG_T [User Group / Specialized Tenant]
UG_ID[Group: Hanmac 운영팀]
end
subgraph Child_T [Child Tenants / 하위 테넌트]
T1[Tenant: 한맥 엔지니어링]
T2[Tenant: 한맥 IT]
end
%% Policy Links
Leader -- "Explicitly Assigned" --> UG_ID
Leader -. "Automatically Becomes" .-> Admin[Tenant Admin]
Member -- "is member of" --> UG_ID
%% Inheritance (Identical Logic)
UG_ID -- "Inherits Access To" --> T1
UG_ID -- "Inherits Access To" --> T2
%% Effective Access
Admin -- "Full Control" --> UG_ID
Member -- "Shared Access" --> T1
Member -- "Shared Access" --> T2
%% Styles
style UG_ID fill:#f9f,stroke:#333,stroke-width:2px
style Leader fill:#ff9,stroke:#333
style Admin fill:#ffd,stroke:#333,stroke-dasharray: 5 5
```
## 3. 기술적 구현 가이드 (Implementation)
### 3.1 Keto Relationship Tuples
- **그룹장 임명:** `UserGroup:<ID>#owners@User:<UserID>`
- **어드민 자동 승격:** `Tenant:<ID>#admins@UserGroup:<ID>#owners` (그룹 소유자는 해당 테넌트의 어드민)
- **멤버십:** `UserGroup:<ID>#members@User:<UserID>`
### 3.2 기대 효과
- **정책 단순화:** '어드민'과 '그룹장'을 별도로 관리할 필요가 없어 시스템 복잡도가 감소합니다.
- **책임 명확화:** 그룹의 장이 해당 자원의 최종 책임자가 되는 직관적인 거버넌스를 수립합니다.
- **일관된 UX:** 사용자는 자신이 관리하는 것이 '테넌트'인지 '그룹'인지 고민할 필요 없이 동일한 관리 도구를 사용합니다.

View File

@@ -0,0 +1,83 @@
# 유저 그룹 기반 ReBAC 권한 아키텍처 (User Group-based ReBAC)
이 문서는 Baron SSO의 이슈 #239를 통해 구현된 유저 그룹 중심의 권한 체계와 Ory Keto를 이용한 ReBAC(Relationship-Based Access Control) 설계 방식을 설명합니다.
## 1. 개요
기존의 '테넌트 그룹(Tenant Group)' 방식에서 '유저 그룹(User Group)' 방식으로 전환하여, 권한 부여의 주체(Subject)를 그룹화하고 자원(Tenant)에 대한 권한을 상속받는 구조로 설계되었습니다.
## 2. 권한 상속 다이어그램
```mermaid
graph TD
%% Entities
subgraph Identity [사용자 계정]
U1[User: A]
U2[User: B]
end
subgraph Subjects [권한 부여 주체]
UG[User Group: 개발팀]
end
subgraph Resources [보호 대상 자원]
T1[Tenant: Project Alpha]
T2[Tenant: Project Beta]
RP[Relying Party: Auth App]
end
%% Relationships
U1 -- "is member of" --> UG
U2 -- "is member of" --> UG
UG -- "assigned role: manage" --> T1
UG -- "assigned role: view" --> T2
%% Inheritance Logic (Keto ReBAC)
T1 -- "owns" --> RP
%% Direct Inheritance
U1 -. "inherits: manage" .-> T1
U1 -. "inherits: view" .-> T2
U2 -. "inherits: manage" .-> T1
%% Recursive Permission
T1 -. "allows access" .-> RP
U1 -. "can manage" .-> RP
%% Styles
style Identity fill:#f9f,stroke:#333,stroke-width:2px
style Subjects fill:#bbf,stroke:#333,stroke-width:2px
style Resources fill:#dfd,stroke:#333,stroke-width:2px
linkStyle 4,5,6,7,8,9 stroke:#ff944d,stroke-width:2px,stroke-dasharray: 5 5
```
## 3. 기술적 관계 설계 (Ory Keto Tuples)
Ory Keto 내부적으로는 다음과 같은 관계 튜플(Relationship Tuples)을 통해 권한을 관리합니다.
### 3.1 그룹 멤버십 (Group Membership)
사용자를 특정 유저 그룹의 멤버로 등록합니다.
- **Tuple:** `UserGroup:<GroupID>#members@User:<UserID>`
- **의미:** `UserID` 사용자는 `GroupID` 유저 그룹의 멤버이다.
### 3.2 테넌트 권한 할당 (Tenant Role Assignment)
유저 그룹 전체에 특정 테넌트에 대한 역할을 부여합니다.
- **Tuple:** `Tenant:<TenantID>#<Relation>@UserGroup:<GroupID>#members`
- **의미:** `GroupID` 유저 그룹의 모든 멤버는 `TenantID` 테넌트에 대해 `<Relation>`(예: `view`, `manage`, `admins`) 권한을 가진다.
### 3.3 자원 소유 및 전파 (Resource Ownership)
테넌트가 소유한 하위 자원(RP, API Key 등)에 대한 권한 전파 규칙입니다.
- **Tuple:** `RelyingParty:<ClientID>#parents@Tenant:<TenantID>`
- **검증 논리:** 사용자가 `ClientID`에 대한 `view` 권한을 요청하면, Keto는 해당 사용자가 부모인 `TenantID`에 대해 `view` 권한이 있는지 역추적하여 승인합니다.
## 4. 주요 장점
1. **중앙 집중식 관리:** 사용자의 부서 이동이나 퇴사 시, 개별 테넌트의 권한을 수정할 필요 없이 유저 그룹의 멤버십만 변경하면 모든 연관 권한이 즉시 회수/부여됩니다.
2. **복합 권한 구성:** 하나의 그룹이 여러 테넌트에 대해 서로 다른 수준의 권한을 가질 수 있어, 실제 조직 구조와 프로젝트 협업 모델을 유연하게 반영할 수 있습니다.
3. **Zanzibar 스타일 확장성:** Google Zanzibar 논리를 따르는 Ory Keto를 활용함으로써, 향후 수만 명의 사용자와 수천 개의 테넌트 환경에서도 성능 저하 없이 정교한 권한 체크가 가능합니다.
## 5. 관련 구현 파일
- **Backend Service:** `backend/internal/service/user_group_service.go`
- **Backend Handler:** `backend/internal/handler/user_group_handler.go`
- **Frontend API:** `adminfront/src/lib/adminApi.ts`
- **Frontend UI:** `adminfront/src/features/user-groups/`