forked from baron/baron-sso
feat: 구현: 유저 그룹 중심 권한 통합 및 미들웨어 정책 고도화
This commit is contained in:
957
adminfront/package-lock.json
generated
957
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
"@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-scroll-area": "^1.1.2",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-router-dom": "^6.28.2",
|
"react-router-dom": "^6.28.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,18 +8,15 @@ import AuthPage from "../features/auth/AuthPage";
|
|||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
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 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 TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
|
||||||
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";
|
||||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
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 UserCreatePage from "../features/users/UserCreatePage";
|
||||||
import UserDetailPage from "../features/users/UserDetailPage";
|
import UserDetailPage from "../features/users/UserDetailPage";
|
||||||
import UserListPage from "../features/users/UserListPage";
|
import UserListPage from "../features/users/UserListPage";
|
||||||
@@ -45,28 +42,23 @@ export const router = createBrowserRouter(
|
|||||||
{ path: "users", element: <UserListPage /> },
|
{ path: "users", element: <UserListPage /> },
|
||||||
{ path: "users/new", element: <UserCreatePage /> },
|
{ path: "users/new", element: <UserCreatePage /> },
|
||||||
{ path: "users/:id", element: <UserDetailPage /> },
|
{ path: "users/:id", element: <UserDetailPage /> },
|
||||||
|
{ path: "user-groups", element: <GlobalUserGroupListPage /> },
|
||||||
{ path: "tenants", element: <TenantListPage /> },
|
{ path: "tenants", element: <TenantListPage /> },
|
||||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
{ 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",
|
path: "tenants/:tenantId",
|
||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
{ path: "admins", element: <TenantAdminsTab /> },
|
{ path: "admins", element: <TenantAdminsTab /> },
|
||||||
|
{ path: "user-groups", element: <TenantUserGroupsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "tenants/:tenantId/user-groups/:id",
|
||||||
|
element: <UserGroupDetailPage />,
|
||||||
|
},
|
||||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ const navItems = [
|
|||||||
icon: ShieldHalf,
|
icon: ShieldHalf,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "ui.admin.nav.tenant_groups",
|
label: "ui.admin.nav.tenants",
|
||||||
to: "/tenant-groups",
|
to: "/tenants",
|
||||||
icon: LayoutGrid,
|
icon: Building2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "ui.admin.nav.user_groups",
|
||||||
|
to: "/user-groups",
|
||||||
|
icon: Users,
|
||||||
},
|
},
|
||||||
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
|
|
||||||
{ label: "ui.admin.nav.users", to: "/users", 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.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 },
|
||||||
|
|||||||
120
adminfront/src/components/ui/dialog.tsx
Normal file
120
adminfront/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
158
adminfront/src/components/ui/select.tsx
Normal file
158
adminfront/src/components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -17,6 +17,7 @@ function TenantDetailPage() {
|
|||||||
|
|
||||||
const isFederationTab = location.pathname.includes("/federation");
|
const isFederationTab = location.pathname.includes("/federation");
|
||||||
const isAdminTab = location.pathname.includes("/admins");
|
const isAdminTab = location.pathname.includes("/admins");
|
||||||
|
const isUserGroupsTab = location.pathname.includes("/user-groups");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -74,6 +75,16 @@ function TenantDetailPage() {
|
|||||||
>
|
>
|
||||||
Admins
|
Admins
|
||||||
</Link>
|
</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
|
<Link
|
||||||
to={`/tenants/${tenantId}/schema`}
|
to={`/tenants/${tenantId}/schema`}
|
||||||
className={`px-4 py-2 text-sm font-medium ${
|
className={`px-4 py-2 text-sm font-medium ${
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -186,7 +186,14 @@ 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}/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;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -196,22 +203,71 @@ export async function createGroup(
|
|||||||
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}/user-groups`,
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroup(groupId: string) {
|
export async function deleteGroup(tenantId: string, groupId: string) {
|
||||||
await apiClient.delete(`/v1/admin/groups/${groupId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addGroupMember(groupId: string, userId: string) {
|
export async function addGroupMember(
|
||||||
await apiClient.post(`/v1/admin/groups/${groupId}/members`, { userId });
|
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) {
|
export async function removeGroupMember(
|
||||||
await apiClient.delete(`/v1/admin/groups/${groupId}/members/${userId}`);
|
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}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tenant Group Management (Global Grouping of Tenants)
|
// Tenant Group Management (Global Grouping of Tenants)
|
||||||
|
|||||||
@@ -245,28 +245,25 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
tenantRepo := repository.NewTenantRepository(db)
|
tenantRepo := repository.NewTenantRepository(db)
|
||||||
tenantGroupRepo := repository.NewTenantGroupRepository(db)
|
|
||||||
userGroupRepo := repository.NewUserGroupRepository(db)
|
userGroupRepo := repository.NewUserGroupRepository(db)
|
||||||
userRepo := repository.NewUserRepository(db)
|
userRepo := repository.NewUserRepository(db)
|
||||||
|
kratosAdminService := service.NewKratosAdminService()
|
||||||
|
oryAdminProvider := service.NewOryProvider()
|
||||||
|
|
||||||
tenantService := service.NewTenantService(tenantRepo)
|
tenantService := service.NewTenantService(tenantRepo)
|
||||||
tenantGroupService := service.NewTenantGroupService(tenantGroupRepo, ketoService)
|
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService)
|
||||||
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, ketoService)
|
|
||||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||||
// relyingPartyRepo removed as SSOT is now Hydra+Keto
|
|
||||||
hydraService := service.NewHydraAdminService()
|
hydraService := service.NewHydraAdminService()
|
||||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
|
||||||
secretRepo := repository.NewClientSecretRepository(db)
|
secretRepo := repository.NewClientSecretRepository(db)
|
||||||
consentRepo := repository.NewClientConsentRepository(db)
|
consentRepo := repository.NewClientConsentRepository(db)
|
||||||
|
|
||||||
kratosAdminService := service.NewKratosAdminService()
|
|
||||||
oryAdminProvider := service.NewOryProvider()
|
|
||||||
|
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
|
||||||
adminHandler := handler.NewAdminHandler(ketoService)
|
adminHandler := handler.NewAdminHandler(ketoService)
|
||||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
|
||||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
|
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
|
||||||
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
|
|
||||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
|
||||||
@@ -576,29 +573,18 @@ func main() {
|
|||||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
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)
|
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||||
|
|
||||||
// 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)
|
|
||||||
admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
|
|
||||||
admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
|
|
||||||
admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
|
|
||||||
|
|
||||||
// User Group Management (Tenant Admin/Super Admin)
|
// User Group Management (Tenant Admin/Super Admin)
|
||||||
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"))
|
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin)
|
||||||
userGroups.Get("/", userGroupHandler.List)
|
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
|
||||||
userGroups.Post("/", userGroupHandler.Create)
|
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
|
||||||
userGroups.Get("/:id", userGroupHandler.Get)
|
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거
|
||||||
userGroups.Put("/:id", userGroupHandler.Update)
|
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
|
||||||
userGroups.Delete("/:id", userGroupHandler.Delete)
|
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
|
||||||
userGroups.Post("/:id/members", userGroupHandler.AddMember)
|
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
|
||||||
userGroups.Delete("/:id/members/:userId", userGroupHandler.RemoveMember)
|
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
|
||||||
userGroups.Post("/:id/roles", userGroupHandler.AssignRole)
|
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
|
||||||
userGroups.Delete("/:id/roles/:tenantId/:relation", userGroupHandler.RemoveRole)
|
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)
|
// Relying Party Management (Global List)
|
||||||
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ 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{},
|
||||||
|
&domain.UserGroup{},
|
||||||
&domain.ApiKey{},
|
&domain.ApiKey{},
|
||||||
&domain.IdentityProviderConfig{},
|
&domain.IdentityProviderConfig{},
|
||||||
&domain.ClientSecret{},
|
&domain.ClientSecret{},
|
||||||
|
|||||||
@@ -25,18 +25,6 @@ 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,47 +17,23 @@ 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
|
||||||
TenantGroupID *string `gorm:"type:uuid;index" json:"tenantGroupId,omitempty"`
|
Name string `gorm:"not null" json:"name"`
|
||||||
TenantGroup *TenantGroup `gorm:"foreignKey:TenantGroupID" json:"tenantGroup,omitempty"`
|
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Description string `json:"description"`
|
||||||
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
|
Status string `gorm:"default:'pending'" json:"status"`
|
||||||
Description string `json:"description"`
|
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
||||||
Status string `gorm:"default:'pending'" json:"status"`
|
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
||||||
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tenant) IsActive() bool {
|
func (t *Tenant) IsActive() bool {
|
||||||
return t.Status == TenantStatusActive
|
return t.Status == TenantStatusActive
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMergedConfig merges the group-level config with tenant-level config.
|
|
||||||
// Tenant config takes precedence.
|
|
||||||
func (t *Tenant) GetMergedConfig() JSONMap {
|
|
||||||
merged := make(JSONMap)
|
|
||||||
|
|
||||||
// 1. Apply Group Config (Base)
|
|
||||||
if t.TenantGroup != nil && t.TenantGroup.Config != nil {
|
|
||||||
for k, v := range t.TenantGroup.Config {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Apply Tenant Config (Overrides)
|
|
||||||
if t.Config != nil {
|
|
||||||
for k, v := range t.Config {
|
|
||||||
merged[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeforeCreate hook to generate UUID if not present.
|
// BeforeCreate hook to generate UUID if not present.
|
||||||
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
|
||||||
if t.ID == "" {
|
if t.ID == "" {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,12 @@ type UserGroup struct {
|
|||||||
Members []User `gorm:"-" json:"members,omitempty"`
|
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 {
|
func (ug *UserGroup) TableName() string {
|
||||||
return "user_groups"
|
return "user_groups"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
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
|
|
||||||
UserService *service.KratosAdminService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
|
|
||||||
return &TenantGroupHandler{Service: svc, UserService: userSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
|
|
||||||
if err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminInfo struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
}
|
|
||||||
|
|
||||||
admins := make([]adminInfo, 0, len(userIDs))
|
|
||||||
for _, uid := range userIDs {
|
|
||||||
identity, err := h.UserService.GetIdentity(c.Context(), uid)
|
|
||||||
if err == nil && identity != nil {
|
|
||||||
name, _ := identity.Traits["name"].(string)
|
|
||||||
email, _ := identity.Traits["email"].(string)
|
|
||||||
admins = append(admins, adminInfo{
|
|
||||||
ID: uid,
|
|
||||||
Name: name,
|
|
||||||
Email: email,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Fallback if identity not found in Kratos
|
|
||||||
admins = append(admins, adminInfo{ID: uid})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(admins)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userID := c.Params("userId")
|
|
||||||
|
|
||||||
if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "admin added to group"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
|
|
||||||
groupID := c.Params("id")
|
|
||||||
userID := c.Params("userId")
|
|
||||||
|
|
||||||
if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
|
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
||||||
}
|
|
||||||
return c.JSON(fiber.Map{"message": "admin 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,16 +23,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
TenantGroupID *string `json:"tenantGroupId,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
CreatedAt string `json:"createdAt"`
|
||||||
CreatedAt string `json:"createdAt"`
|
UpdatedAt string `string:"updatedAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
@@ -103,7 +102,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Preload("TenantGroup").Find(&tenants).Error; err != nil {
|
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +125,7 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var tenant domain.Tenant
|
var tenant domain.Tenant
|
||||||
if err := h.DB.Preload("Domains").Preload("TenantGroup").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
if err := h.DB.Preload("Domains").First(&tenant, "id = ?", tenantID).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "tenant not found"})
|
||||||
}
|
}
|
||||||
@@ -211,7 +210,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
TenantGroupID *string `json:"tenantGroupId"`
|
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
@@ -255,29 +253,6 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
tenant.Config = req.Config
|
tenant.Config = req.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Group Change
|
|
||||||
if req.TenantGroupID != nil {
|
|
||||||
oldGroupID := tenant.TenantGroupID
|
|
||||||
newGroupID := req.TenantGroupID
|
|
||||||
if *newGroupID == "" {
|
|
||||||
newGroupID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Keto if group changed
|
|
||||||
if h.Keto != nil {
|
|
||||||
// Remove old group relation if existed
|
|
||||||
if oldGroupID != nil && (newGroupID == nil || *oldGroupID != *newGroupID) {
|
|
||||||
_ = h.Keto.DeleteRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *oldGroupID)
|
|
||||||
}
|
|
||||||
// Add new group relation
|
|
||||||
if newGroupID != nil && (oldGroupID == nil || *oldGroupID != *newGroupID) {
|
|
||||||
_ = h.Keto.CreateRelation(c.Context(), "Tenant", tenant.ID, "parent_group", *newGroupID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tenant.TenantGroupID = newGroupID
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.DB.Save(&tenant).Error; err != nil {
|
if err := h.DB.Save(&tenant).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -387,16 +362,15 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return tenantSummary{
|
return tenantSummary{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Slug: t.Slug,
|
Slug: t.Slug,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
Status: t.Status,
|
Status: t.Status,
|
||||||
TenantGroupID: t.TenantGroupID,
|
Domains: domains,
|
||||||
Domains: domains,
|
Config: t.Config,
|
||||||
Config: t.GetMergedConfig(),
|
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
||||||
CreatedAt: t.CreatedAt.Format(time.RFC3339),
|
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: t.UpdatedAt.Format(time.RFC3339),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
|
|||||||
id := c.Params("id")
|
id := c.Params("id")
|
||||||
group, err := h.Service.Get(c.Context(), id)
|
group, err := h.Service.Get(c.Context(), id)
|
||||||
if err != nil {
|
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)
|
return c.JSON(group)
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,15 @@ func (h *UserGroupHandler) AssignRole(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusOK)
|
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 {
|
func (h *UserGroupHandler) RemoveRole(c *fiber.Ctx) error {
|
||||||
groupID := c.Params("id")
|
groupID := c.Params("id")
|
||||||
tenantID := c.Params("tenantId")
|
tenantID := c.Params("tenantId")
|
||||||
|
|||||||
@@ -37,15 +37,23 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get object ID from path (e.g., tenant ID)
|
// Get object ID from path (e.g., tenant ID)
|
||||||
objectID := c.Params("id")
|
// Fix: For Tenant namespace, prioritize tenantId param if available
|
||||||
if objectID == "" {
|
objectID := ""
|
||||||
|
if namespace == "Tenant" {
|
||||||
objectID = c.Params("tenantId")
|
objectID = c.Params("tenantId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if objectID == "" {
|
||||||
|
objectID = c.Params("id")
|
||||||
|
}
|
||||||
|
|
||||||
if objectID == "" {
|
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"})
|
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
|
// Set tenant_id for audit logging if namespace is Tenant
|
||||||
if namespace == "Tenant" {
|
if namespace == "Tenant" {
|
||||||
c.Locals("tenant_id", objectID)
|
c.Locals("tenant_id", objectID)
|
||||||
@@ -53,9 +61,14 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
|
|
||||||
// Check with Keto
|
// Check with Keto
|
||||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
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)
|
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()
|
return c.Next()
|
||||||
@@ -141,7 +154,26 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler {
|
|||||||
targetTenantID = c.Params("id") // common for /tenants/:id
|
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{
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||||
"error": "forbidden: you do not have access to this tenant",
|
"error": "forbidden: you do not have access to this tenant",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -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) {
|
func (r *userGroupRepository) FindByID(ctx context.Context, id string) (*domain.UserGroup, error) {
|
||||||
var group domain.UserGroup
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &group, nil
|
return &group, nil
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ func (s *ketoService) CreateRelation(ctx context.Context, namespace, object, rel
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ketoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
|
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 := u.Query()
|
||||||
q.Set("namespace", namespace)
|
q.Set("namespace", namespace)
|
||||||
q.Set("object", object)
|
q.Set("object", object)
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
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
|
|
||||||
AddGroupAdmin(ctx context.Context, groupID, userID string) error
|
|
||||||
RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
|
|
||||||
ListGroupAdmins(ctx context.Context, groupID string) ([]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
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
|
|
||||||
if s.keto == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
|
|
||||||
if s.keto == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
|
|
||||||
if s.keto == nil {
|
|
||||||
return []string{}, nil
|
|
||||||
}
|
|
||||||
tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDs := make([]string, 0, len(tuples))
|
|
||||||
for _, t := range tuples {
|
|
||||||
// subject_id is "User:uuid"
|
|
||||||
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
|
||||||
userIDs = append(userIDs, t.SubjectID[5:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return userIDs, nil
|
|
||||||
}
|
|
||||||
@@ -48,40 +48,54 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
|||||||
return nil, errors.New("keto service not initialized")
|
return nil, errors.New("keto service not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Get directly managed tenants
|
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
|
||||||
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", userID)
|
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to list directly managed tenants from Keto", "userID", userID, "error", err)
|
slog.Error("Failed to list direct tenants", "userID", userID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get managed tenant groups
|
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID)
|
||||||
groupIDs, err := s.keto.ListObjects(ctx, "TenantGroup", "admins", userID)
|
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다.
|
||||||
|
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to list managed tenant groups from Keto", "userID", userID, "error", err)
|
slog.Error("Failed to list owned groups", "userID", userID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Get tenants belonging to those groups
|
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
|
||||||
var groupInheritedTenantIDs []string
|
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
|
||||||
for _, groupID := range groupIDs {
|
if err != nil {
|
||||||
// In Keto, we defined: Tenant#parent_group@TenantGroup:GroupID#_
|
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
|
||||||
// To find tenants in a group, we look for relations where namespace=Tenant, relation=parent_group, subject=TenantGroup:GroupID#_
|
}
|
||||||
// Wait, my ListObjects lists objects given a subject.
|
|
||||||
// So subject="TenantGroup:"+groupID+"#_"
|
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
|
||||||
// Object is Tenant ID.
|
var inheritedTenantIDs []string
|
||||||
ts, err := s.keto.ListRelations(ctx, "Tenant", "", "parent_group", "TenantGroup:"+groupID)
|
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
|
||||||
|
for _, groupID := range allMyGroups {
|
||||||
|
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
|
||||||
|
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, t := range ts {
|
for _, r := range relations {
|
||||||
groupInheritedTenantIDs = append(groupInheritedTenantIDs, t.Object)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine and deduplicate IDs
|
// 합산 및 중복 제거
|
||||||
allIDsMap := make(map[string]bool)
|
allIDsMap := make(map[string]bool)
|
||||||
for _, id := range directTenantIDs {
|
for _, id := range directTenantIDs {
|
||||||
allIDsMap[id] = true
|
allIDsMap[id] = true
|
||||||
}
|
}
|
||||||
for _, id := range groupInheritedTenantIDs {
|
for _, id := range ownedGroupIDs {
|
||||||
|
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
|
||||||
|
}
|
||||||
|
for _, id := range inheritedTenantIDs {
|
||||||
allIDsMap[id] = true
|
allIDsMap[id] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type UserGroupService interface {
|
|||||||
RemoveMember(ctx context.Context, groupID, userID string) error
|
RemoveMember(ctx context.Context, groupID, userID string) error
|
||||||
|
|
||||||
// Permission Management
|
// Permission Management
|
||||||
|
ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error)
|
||||||
AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error
|
AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error
|
||||||
RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error
|
RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error
|
||||||
}
|
}
|
||||||
@@ -26,14 +27,24 @@ type UserGroupService interface {
|
|||||||
type userGroupService struct {
|
type userGroupService struct {
|
||||||
repo repository.UserGroupRepository
|
repo repository.UserGroupRepository
|
||||||
userRepo repository.UserRepository
|
userRepo repository.UserRepository
|
||||||
|
tenantRepo repository.TenantRepository
|
||||||
ketoService KetoService
|
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{
|
return &userGroupService{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
|
tenantRepo: tenantRepo,
|
||||||
ketoService: keto,
|
ketoService: keto,
|
||||||
|
kratos: kratos,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,36 +81,75 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
|
|||||||
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var userIDs []string
|
var userIDs []string
|
||||||
for _, t := range tuples {
|
for _, t := range tuples {
|
||||||
// SubjectID is like "User:uuid"
|
sid := t.SubjectID
|
||||||
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
|
if len(sid) > 5 && sid[:5] == "User:" {
|
||||||
userIDs = append(userIDs, t.SubjectID[5:])
|
userIDs = append(userIDs, sid[5:])
|
||||||
|
} else {
|
||||||
|
userIDs = append(userIDs, sid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(userIDs) > 0 {
|
if len(userIDs) > 0 {
|
||||||
|
// 1. Try to find in local DB
|
||||||
members, err := s.userRepo.FindByIDs(ctx, userIDs)
|
members, err := s.userRepo.FindByIDs(ctx, userIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to fetch member details from db", "error", err)
|
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
|
return group, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
|
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 {
|
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
|
||||||
@@ -124,6 +174,44 @@ func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID str
|
|||||||
return nil
|
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 {
|
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
|
||||||
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
|
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members
|
||||||
// This means all members of the group have the relation on the tenant.
|
// This means all members of the group have the relation on the tenant.
|
||||||
|
|||||||
143
docs/keto-rebac-policy-guide.md
Normal file
143
docs/keto-rebac-policy-guide.md
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -18,9 +18,9 @@
|
|||||||
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
|
- Keto의 관계 튜플에 기반해 `CheckPermission`을 수행합니다.
|
||||||
|
|
||||||
### 2.3 RequireTenantMatch
|
### 2.3 RequireTenantMatch
|
||||||
- 테넌트 관리자 권한을 가진 사용자가 **자신의 테넌트**에만 접근하도록 보장합니다.
|
- 사용자가 요청한 테넌트에 대한 관리 자격이 있는지 검증합니다.
|
||||||
- Super Admin은 즉시 통과합니다.
|
- **상속 권한 인정:** 사용자의 기본 테넌트뿐만 아니라, 유저 그룹 멤버십이나 그룹장 직책을 통해 **상속받은 모든 테넌트**를 대상으로 합니다.
|
||||||
- API Key 인증은 우회합니다.
|
- Super Admin 및 유효한 API Key 요청은 통과합니다.
|
||||||
|
|
||||||
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
|
## 3. ReBAC 기반인데도 RBAC가 필요한 이유
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
|
- 불필요한 ReBAC 호출을 줄여 장애 전파를 줄입니다.
|
||||||
|
|
||||||
3) **테넌트 범위 제어의 명확성**
|
3) **테넌트 범위 제어의 명확성**
|
||||||
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다.
|
- "Tenant Admin은 자기 테넌트만"은 자주 쓰는 규칙으로, 미들웨어 단에서 즉시 판단이 효율적입니다. 유저 그룹 도입 이후에는 "상속받은 모든 관리 대상 테넌트"로 범위가 확장됩니다.
|
||||||
|
|
||||||
4) **성능 및 안정성**
|
4) **성능 및 안정성**
|
||||||
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
|
- Keto는 외부 서비스 호출이므로 지연/실패 가능성이 있습니다.
|
||||||
@@ -48,16 +48,19 @@
|
|||||||
|
|
||||||
### 4.2 권한/정책 SoT
|
### 4.2 권한/정책 SoT
|
||||||
- **1순위: Keto(ReBAC) 관계 튜플**
|
- **1순위: Keto(ReBAC) 관계 튜플**
|
||||||
- 리소스 접근 권한의 최종 판단 기준
|
- 리소스 접근 권한의 최종 판단 기준.
|
||||||
|
- **유저 그룹 상속:** 사용자가 속한 유저 그룹에 부여된 권한은 Keto를 통해 실시간으로 상속됩니다.
|
||||||
|
- **그룹장-어드민 연동:** 유저 그룹의 장(Leader)은 해당 그룹(테넌트)의 어드민 권한을 자동으로 가집니다.
|
||||||
- **2순위: RBAC(Role)**
|
- **2순위: RBAC(Role)**
|
||||||
- 전역/상위 정책의 단축 규칙
|
- 전역/상위 정책의 단축 규칙.
|
||||||
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선
|
- ReBAC와 충돌 시, ReBAC 결과가 항상 우선.
|
||||||
|
|
||||||
### 4.3 테넌트 컨텍스트 SoT
|
### 4.3 테넌트 컨텍스트 SoT
|
||||||
- **1순위: 서버 측 프로필(예: UserProfile.tenantId)**
|
- **1순위: 서버 측 프로필 및 상속된 권한 (ManageableTenants)**
|
||||||
|
- 사용자의 기본 `tenantId`뿐만 아니라, 유저 그룹을 통해 **상속받은 관리 가능 테넌트 목록** 전체를 기준으로 판단합니다.
|
||||||
- **2순위: 요청 헤더(X-Tenant-ID)**
|
- **2순위: 요청 헤더(X-Tenant-ID)**
|
||||||
- 헤더는 "요청 의도"를 나타내지만, 항상 서버 프로필과 일치해야 함
|
- 헤더는 "요청 의도"를 나타내며, `ManageableTenants` 목록에 포함된 ID여야 합니다.
|
||||||
- 불일치 시 차단
|
- 불일치 시 차단.
|
||||||
|
|
||||||
### 4.4 OIDC/RP 정보 SoT
|
### 4.4 OIDC/RP 정보 SoT
|
||||||
- **1순위: Hydra Client/Consent 데이터**
|
- **1순위: Hydra Client/Consent 데이터**
|
||||||
|
|||||||
68
docs/tenant-usergroup-policy.md
Normal file
68
docs/tenant-usergroup-policy.md
Normal 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:** 사용자는 자신이 관리하는 것이 '테넌트'인지 '그룹'인지 고민할 필요 없이 동일한 관리 도구를 사용합니다.
|
||||||
83
docs/user-group-rebac-architecture.md
Normal file
83
docs/user-group-rebac-architecture.md
Normal 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/`
|
||||||
Reference in New Issue
Block a user