From 68df43f3a83d995cc3229f51a8268e4b1db1a13e Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 11 Feb 2026 13:26:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=85=8C=EB=84=8C=ED=8A=B8/RP=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=95=A0=EB=8B=B9=20UI=20?= =?UTF-8?q?=EB=B0=8F=20ReBAC=20=EA=B6=8C=ED=95=9C=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=8F=84=EA=B5=AC=20=EA=B5=AC=ED=98=84=20#244?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 17 ++ .../src/components/layout/AppLayout.tsx | 55 ++--- .../features/overview/GlobalOverviewPage.tsx | 3 + .../overview/components/PermissionChecker.tsx | 133 ++++++++++++ .../relying-parties/routes/RPDetailPage.tsx | 77 +++++++ .../relying-parties/routes/RPListPage.tsx | 146 +++++++++++++ .../relying-parties/routes/RPOwnersTab.tsx | 201 ++++++++++++++++++ .../relying-parties/routes/RPProfileTab.tsx | 82 +++++++ .../routes/TenantGroupAdminsTab.tsx | 199 +++++++++++++++++ .../routes/TenantGroupDetailPage.tsx | 13 +- .../tenants/routes/TenantAdminsTab.tsx | 198 +++++++++++++++++ .../tenants/routes/TenantDetailPage.tsx | 13 +- adminfront/src/lib/adminApi.ts | 96 ++++++++- adminfront/src/locales/en.toml | 1 + adminfront/src/locales/ko.toml | 1 + backend/cmd/server/main.go | 27 ++- backend/internal/handler/admin_handler.go | 35 ++- backend/internal/handler/dev_handler.go | 26 +++ .../internal/handler/relying_party_handler.go | 60 +++++- .../internal/handler/tenant_group_handler.go | 60 +++++- backend/internal/handler/tenant_handler.go | 57 ++++- .../internal/service/relying_party_service.go | 24 +++ .../internal/service/tenant_group_service.go | 36 ++++ backend/internal/service/tenant_service.go | 35 +++ 24 files changed, 1547 insertions(+), 48 deletions(-) create mode 100644 adminfront/src/features/overview/components/PermissionChecker.tsx create mode 100644 adminfront/src/features/relying-parties/routes/RPDetailPage.tsx create mode 100644 adminfront/src/features/relying-parties/routes/RPListPage.tsx create mode 100644 adminfront/src/features/relying-parties/routes/RPOwnersTab.tsx create mode 100644 adminfront/src/features/relying-parties/routes/RPProfileTab.tsx create mode 100644 adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantAdminsTab.tsx diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index e4ff81b2..ac0af8fe 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -7,16 +7,22 @@ import AuthPage from "../features/auth/AuthPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import LoginPage from "../features/auth/LoginPage"; +import RPDetailPage from "../features/relying-parties/routes/RPDetailPage"; +import RPListPage from "../features/relying-parties/routes/RPListPage"; +import RPOwnersTab from "../features/relying-parties/routes/RPOwnersTab"; +import RPProfileTab from "../features/relying-parties/routes/RPProfileTab"; 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 TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; +import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; import UserCreatePage from "../features/users/UserCreatePage"; import UserDetailPage from "../features/users/UserDetailPage"; import UserListPage from "../features/users/UserListPage"; @@ -48,6 +54,7 @@ export const router = createBrowserRouter( children: [ { index: true, element: }, { path: "tenants", element: }, + { path: "admins", element: }, ], }, { @@ -55,11 +62,21 @@ export const router = createBrowserRouter( element: , children: [ { index: true, element: }, + { path: "admins", element: }, { path: "schema", element: }, ], }, { path: "api-keys", element: }, { path: "api-keys/new", element: }, + { path: "relying-parties", element: }, + { + path: "relying-parties/:id", + element: , + children: [ + { index: true, element: }, + { path: "owners", element: }, + ], + }, ], }, ], diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 20e07755..aedfb714 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -5,33 +5,34 @@ import { KeyRound, LayoutDashboard, LayoutGrid, - LogOut, - Moon, - NotebookTabs, - ShieldHalf, - Sun, - Users, - } from "lucide-react"; - import { useEffect, useState } from "react"; - import { NavLink, Outlet, useNavigate } from "react-router-dom"; - import { t } from "../../lib/i18n"; - import RoleSwitcher from "./RoleSwitcher"; - - const navItems = [ - { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, - { - label: "ui.admin.nav.tenant_dashboard", - to: "/dashboard", - icon: ShieldHalf, - }, - { label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid }, - { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, - { label: "ui.admin.nav.users", to: "/users", icon: Users }, - { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, - { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, - { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, - ]; - + LogOut, + Moon, + NotebookTabs, + Rocket, + ShieldHalf, + Sun, + Users, + } from "lucide-react"; + import { useEffect, useState } from "react"; + import { NavLink, Outlet, useNavigate } from "react-router-dom"; + import { t } from "../../lib/i18n"; + import RoleSwitcher from "./RoleSwitcher"; + + const navItems = [ + { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, + { + label: "ui.admin.nav.tenant_dashboard", + to: "/dashboard", + icon: ShieldHalf, + }, + { label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid }, + { label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 }, + { label: "ui.admin.nav.relying_parties", to: "/relying-parties", icon: Rocket }, + { label: "ui.admin.nav.users", to: "/users", icon: Users }, + { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, + { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, + { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, + ]; function AppLayout() { const navigate = useNavigate(); const [theme, setTheme] = useState<"light" | "dark">(() => { diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 61a0af2d..7db5a1cd 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -17,6 +17,7 @@ import { CardTitle, } from "../../components/ui/card"; import { t } from "../../lib/i18n"; +import PermissionChecker from "./components/PermissionChecker"; const summaryCards = [ { @@ -216,6 +217,8 @@ function GlobalOverviewPage() { + + ); } diff --git a/adminfront/src/features/overview/components/PermissionChecker.tsx b/adminfront/src/features/overview/components/PermissionChecker.tsx new file mode 100644 index 00000000..65cb4dba --- /dev/null +++ b/adminfront/src/features/overview/components/PermissionChecker.tsx @@ -0,0 +1,133 @@ +import { useMutation } from "@tanstack/react-query"; +import { ShieldAlert, CheckCircle2, XCircle, Search } from "lucide-react"; +import { useState } from "react"; +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 apiClient from "../../../lib/apiClient"; + +type CheckPermissionResponse = { + allowed: boolean; + query: { + namespace: string; + object: string; + relation: string; + subject: string; + }; +}; + +function PermissionChecker() { + const [namespace, setNamespace] = useState("Tenant"); + const [object, setObject] = useState(""); + const [relation, setRelation] = useState("manage"); + const [subject, setSubject] = useState(""); + + const checkMutation = useMutation({ + mutationFn: async () => { + const { data } = await apiClient.get("/v1/admin/debug/check-permission", { + params: { namespace, object, relation, subject }, + }); + return data; + }, + }); + + const result = checkMutation.data; + + return ( + + + + + ReBAC 권한 검증 도구 + + + 특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다. + + + +
+
+ + +
+
+ + setRelation(e.target.value)} + /> +
+
+ + setObject(e.target.value)} + /> +
+
+ + setSubject(e.target.value)} + /> +
+
+ +
+ +
+ + {checkMutation.isSuccess && ( +
+ {result.allowed ? ( + <> + +
Access ALLOWED
+

+ 해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함) +

+ + ) : ( + <> + +
Access DENIED
+

+ 해당 사용자는 요청한 리소스에 대해 권한이 없습니다. +

+ + )} +
+ )} +
+
+ ); +} + +export default PermissionChecker; diff --git a/adminfront/src/features/relying-parties/routes/RPDetailPage.tsx b/adminfront/src/features/relying-parties/routes/RPDetailPage.tsx new file mode 100644 index 00000000..cc5f779f --- /dev/null +++ b/adminfront/src/features/relying-parties/routes/RPDetailPage.tsx @@ -0,0 +1,77 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Rocket } from "lucide-react"; +import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { Badge } from "../../../components/ui/badge"; +import { fetchRelyingParty } from "../../../lib/adminApi"; + +function RPDetailPage() { + const { id } = useParams<{ id: string }>(); + const location = useLocation(); + + const rpQuery = useQuery({ + queryKey: ["relying-party", id], + queryFn: () => fetchRelyingParty(id!), + enabled: !!id, + }); + + const isOwnersTab = location.pathname.endsWith("/owners"); + + return ( +
+
+
+
+ + + Apps + + / + Detail +
+
+
+ +
+

+ {rpQuery.data?.relyingParty?.name ?? "Loading App..."} +

+
+

+ Client ID: {id} +

+
+ Admin only +
+ + {/* Tabs */} +
+ + 기본 설정 + + + 소유자 (권한 관리) + +
+ +
+ +
+
+ ); +} + +export default RPDetailPage; diff --git a/adminfront/src/features/relying-parties/routes/RPListPage.tsx b/adminfront/src/features/relying-parties/routes/RPListPage.tsx new file mode 100644 index 00000000..c8fbee27 --- /dev/null +++ b/adminfront/src/features/relying-parties/routes/RPListPage.tsx @@ -0,0 +1,146 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Pencil, Plus, RefreshCw, Trash2, Rocket } 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 { deleteRelyingParty, fetchAllRelyingParties } from "../../../lib/adminApi"; + +function RPListPage() { + const navigate = useNavigate(); + const query = useQuery({ + queryKey: ["relying-parties"], + queryFn: () => fetchAllRelyingParties(), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => deleteRelyingParty(id), + onSuccess: () => { + query.refetch(); + }, + }); + + const items = query.data ?? []; + + const handleDelete = (id: string, name: string) => { + if (!window.confirm(`애플리케이션 "${name}"을 삭제할까요?`)) { + return; + } + deleteMutation.mutate(id); + }; + + return ( +
+
+
+
+ Apps + / + List +
+

애플리케이션(RP) 목록

+

+ 등록된 OAuth2 클라이언트(Relying Party) 목록입니다. +

+
+
+ +
+
+ + + +
+ + + Relying Party Registry + + + 총 {items.length}개 앱 + +
+ Admin only +
+ + + + + NAME + CLIENT ID + TENANT + ACTIONS + + + + {query.isLoading && ( + + 로딩 중... + + )} + {!query.isLoading && items.length === 0 && ( + + + 등록된 애플리케이션이 없습니다. + + + )} + {items.map((rp) => ( + + {rp.name} + {rp.clientId} + + {rp.tenantId} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +} + +export default RPListPage; diff --git a/adminfront/src/features/relying-parties/routes/RPOwnersTab.tsx b/adminfront/src/features/relying-parties/routes/RPOwnersTab.tsx new file mode 100644 index 00000000..d4b1a15b --- /dev/null +++ b/adminfront/src/features/relying-parties/routes/RPOwnersTab.tsx @@ -0,0 +1,201 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { useOutletContext, useParams } from "react-router-dom"; +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 { Input } from "../../../components/ui/input"; +import { + fetchRPOwners, + addRPOwner, + removeRPOwner, + fetchUsers +} from "../../../lib/adminApi"; + +function RPOwnersTab() { + const { id: clientId } = useParams<{ id: string }>(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + + if (!clientId) return null; + + // 현재 소유자 목록 + const ownersQuery = useQuery({ + queryKey: ["rp-owners", clientId], + queryFn: () => fetchRPOwners(clientId), + enabled: !!clientId, + }); + + // 전체 사용자 목록 (소유자 추가용) + const usersQuery = useQuery({ + queryKey: ["users", { limit: 100, search: searchTerm }], + queryFn: () => fetchUsers(100, 0, searchTerm), + enabled: searchTerm.length > 1, + }); + + const addMutation = useMutation({ + mutationFn: (subject: string) => addRPOwner(clientId, subject), + onSuccess: () => { + ownersQuery.refetch(); + setSearchTerm(""); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (subject: string) => removeRPOwner(clientId, subject), + onSuccess: () => { + ownersQuery.refetch(); + }, + }); + + const handleAddOwner = (userId: string) => { + addMutation.mutate(`User:${userId}`); + }; + + const handleRemoveOwner = (subject: string, name?: string) => { + if (window.confirm(`${name || subject}의 소유 권한을 회수할까요?`)) { + removeMutation.mutate(subject); + } + }; + + return ( +
+ {/* 현재 앱 소유자 */} + + + + + 앱 소유자 + + + 이 애플리케이션의 설정을 관리하고 비밀번호를 회전시킬 수 있는 권한을 가진 사용자들입니다. + + + + + + + 이름/주체 + 유형 + 회수 + + + + {ownersQuery.data?.length === 0 && ( + + + 등록된 소유자가 없습니다. + + + )} + {ownersQuery.data?.map((owner) => ( + + +
{owner.name || owner.subject}
+
{owner.email}
+
+ {owner.type} + + + +
+ ))} +
+
+
+
+ + {/* 사용자 검색 및 추가 */} + + +
+ + + 소유자 추가 + +
+ + 소유자로 추가할 사용자를 검색하세요. + +
+ +
+ + setSearchTerm(e.target.value)} + /> +
+ + + + + 사용자 + 추가 + + + + {searchTerm.length < 2 && ( + + + 사용자 이름을 입력하여 검색하세요. + + + )} + {searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {usersQuery.data?.items.filter(u => !ownersQuery.data?.some(o => o.subject === `User:${u.id}`)).map((user) => ( + + +
{user.name}
+
{user.email}
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +export default RPOwnersTab; diff --git a/adminfront/src/features/relying-parties/routes/RPProfileTab.tsx b/adminfront/src/features/relying-parties/routes/RPProfileTab.tsx new file mode 100644 index 00000000..31d9bffa --- /dev/null +++ b/adminfront/src/features/relying-parties/routes/RPProfileTab.tsx @@ -0,0 +1,82 @@ +import { useOutletContext } from "react-router-dom"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { Badge } from "../../../components/ui/badge"; +import type { RelyingParty, HydraClientReq } from "../../../lib/adminApi"; + +function RPProfileTab() { + const { rp } = useOutletContext<{ + rp: { relyingParty: RelyingParty; oauth2Config: HydraClientReq } + }>(); + + if (!rp) return null; + + return ( +
+ + + 애플리케이션 정보 + + OAuth2 클라이언트의 기본 정보 및 상태입니다. + + + +
+
+ + +
+
+ + +
+
+
+ +
+ + Owner Tenant +
+
+
+ +
+ {rp.oauth2Config.scope?.split(" ").map(s => ( + {s} + ))} +
+
+
+
+ + + + OAuth2 Endpoints + + +
+ + + https://sso.hmac.kr/oidc/oauth2/auth + +
+
+ + + https://sso.hmac.kr/oidc/oauth2/token + +
+
+
+
+ ); +} + +export default RPProfileTab; diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx new file mode 100644 index 00000000..3441a778 --- /dev/null +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupAdminsTab.tsx @@ -0,0 +1,199 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ShieldCheck, Search, 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 { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { Input } from "../../../components/ui/input"; +import { + fetchGroupAdmins, + addGroupAdmin, + removeGroupAdmin, + fetchUsers, + type TenantGroupSummary +} 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 ( +
+ {/* 현재 그룹 관리자 */} + + + + + 그룹 관리자 + + + 이 그룹과 소속 테넌트를 관리할 수 있는 권한을 가진 사용자들입니다. + + + + + + + 이름 + 이메일 + 회수 + + + + {adminsQuery.data?.length === 0 && ( + + + 등록된 관리자가 없습니다. + + + )} + {adminsQuery.data?.map((admin) => ( + + {admin.name || "Unknown"} + {admin.email} + + + + + ))} + +
+
+
+ + {/* 사용자 검색 및 추가 */} + + +
+ + + 관리자 추가 + +
+ + 관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일). + +
+ +
+ + setSearchTerm(e.target.value)} + /> +
+ + + + + 사용자 + 추가 + + + + {searchTerm.length < 2 && ( + + + 사용자 이름을 입력하여 검색하세요. + + + )} + {searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( + + +
{user.name}
+
{user.email}
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +export default TenantGroupAdminsTab; diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx index 83b22ae7..5b65a217 100644 --- a/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx +++ b/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx @@ -15,6 +15,7 @@ function TenantGroupDetailPage() { }); const isTenantsTab = location.pathname.endsWith("/tenants"); + const isAdminTab = location.pathname.endsWith("/admins"); return (
@@ -48,7 +49,7 @@ function TenantGroupDetailPage() { 소속 테넌트 ({groupQuery.data?.tenants?.length ?? 0}) + + 관리자 +
diff --git a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx new file mode 100644 index 00000000..f51f2504 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx @@ -0,0 +1,198 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +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 { Input } from "../../../components/ui/input"; +import { + fetchTenantAdmins, + addTenantAdmin, + removeTenantAdmin, + fetchUsers +} from "../../../lib/adminApi"; + +function TenantAdminsTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + + if (!tenantId) return null; + + // 현재 관리자 목록 + const adminsQuery = useQuery({ + queryKey: ["tenant-admins", tenantId], + queryFn: () => fetchTenantAdmins(tenantId), + enabled: !!tenantId, + }); + + // 전체 사용자 목록 (관리자 추가용) + const usersQuery = useQuery({ + queryKey: ["users", { limit: 100, search: searchTerm }], + queryFn: () => fetchUsers(100, 0, searchTerm), + enabled: searchTerm.length > 1, + }); + + const addMutation = useMutation({ + mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), + onSuccess: () => { + adminsQuery.refetch(); + setSearchTerm(""); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (userId: string) => removeTenantAdmin(tenantId, 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 ( +
+ {/* 현재 테넌트 관리자 */} + + + + + 테넌트 관리자 + + + 이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다. + + + + + + + 이름 + 이메일 + 회수 + + + + {adminsQuery.data?.length === 0 && ( + + + 등록된 관리자가 없습니다. + + + )} + {adminsQuery.data?.map((admin) => ( + + {admin.name || "Unknown"} + {admin.email} + + + + + ))} + +
+
+
+ + {/* 사용자 검색 및 추가 */} + + +
+ + + 관리자 추가 + +
+ + 관리자로 추가할 사용자를 검색하세요 (이름 또는 이메일). + +
+ +
+ + setSearchTerm(e.target.value)} + /> +
+ + + + + 사용자 + 추가 + + + + {searchTerm.length < 2 && ( + + + 사용자 이름을 입력하여 검색하세요. + + + )} + {searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && ( + + + 검색 결과가 없습니다. + + + )} + {usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => ( + + +
{user.name}
+
{user.email}
+
+ + + +
+ ))} +
+
+
+
+
+ ); +} + +export default TenantAdminsTab; diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index eb087b00..d8362077 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -16,6 +16,7 @@ function TenantDetailPage() { }); const isFederationTab = location.pathname.includes("/federation"); + const isAdminTab = location.pathname.includes("/admins"); return (
@@ -44,7 +45,7 @@ function TenantDetailPage() { Federation + + Admins + ( + `/v1/admin/tenants/${tenantId}/admins`, + ); + return data; +} + +export async function addTenantAdmin(tenantId: string, userId: string) { + await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`); +} + +export async function removeTenantAdmin(tenantId: string, userId: string) { + await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`); +} + +export type GroupAdmin = { + id: string; + name: string; + email: string; +}; + +export async function fetchGroupAdmins(groupId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenant-groups/${groupId}/admins`, + ); + return data; +} + +export async function addGroupAdmin(groupId: string, userId: string) { + await apiClient.post(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`); +} + +export async function removeGroupAdmin(groupId: string, userId: string) { + await apiClient.delete( + `/v1/admin/tenant-groups/${groupId}/admins/${userId}`, + ); +} + // API Key Management (M2M) export type ApiKeyCreateRequest = { name: string; @@ -465,5 +509,55 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) { } export async function deleteRelyingParty(id: string) { + await apiClient.delete(`/v1/admin/relying-parties/${id}`); -} \ No newline at end of file + +} + + + +export type RPOwner = { + + subject: string; + + name?: string; + + email?: string; + + type: string; + +}; + + + +export async function fetchRPOwners(clientId: string) { + + const { data } = await apiClient.get( + + `/v1/admin/relying-parties/${clientId}/owners`, + + ); + + return data; + +} + + + +export async function addRPOwner(clientId: string, subject: string) { + + await apiClient.post(`/v1/admin/relying-parties/${clientId}/owners/${subject}`); + +} + + + +export async function removeRPOwner(clientId: string, subject: string) { + + await apiClient.delete( + + `/v1/admin/relying-parties/${clientId}/owners/${subject}`, + + ); + +} diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 5775996e..9667573f 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -1308,3 +1308,4 @@ verify = "Verify" action = "Action" msg.admin.logout_confirm = "Are you sure you want to log out?" ui.admin.nav.logout = "Logout" +ui.admin.nav.relying_parties = "Apps (RP)" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 4f89db40..f35f45e9 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -1308,3 +1308,4 @@ verify = "본인인증" action = "로그인하기" msg.admin.logout_confirm = "로그아웃 하시겠습니까?" ui.admin.nav.logout = "로그아웃" +ui.admin.nav.relying_parties = "애플리케이션(RP)" diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 526cdc0b..05e0347d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -256,15 +256,16 @@ func main() { secretRepo := repository.NewClientSecretRepository(db) consentRepo := repository.NewClientConsentRepository(db) - auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) - adminHandler := handler.NewAdminHandler() - devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) - tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService) - tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService) - relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService) kratosAdminService := service.NewKratosAdminService() oryAdminProvider := service.NewOryProvider() + + auditHandler := handler.NewAuditHandler(auditRepo) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) + adminHandler := handler.NewAdminHandler(ketoService) + devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService) + tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) + tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService) + relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) apiKeyHandler := handler.NewApiKeyHandler(db) @@ -559,6 +560,7 @@ func main() { admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) + admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission) // Tenant Management (Super Admin Only) admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) @@ -567,6 +569,9 @@ func main() { admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant) admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant) admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant) + admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins) + admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin) + admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin) // Tenant Group Management (Super Admin Only) admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups) @@ -576,9 +581,15 @@ func main() { 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) // Relying Party Management (Global List) admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll) + admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners) + admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner) + admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner) // Relying Party Management (Tenant Context) admin.Post("/tenants/:tenantId/relying-parties", @@ -703,4 +714,4 @@ func main() { slog.Error("Server failed to start", "error", err) os.Exit(1) } -} +} \ No newline at end of file diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go index 04c2805e..45b63c75 100644 --- a/backend/internal/handler/admin_handler.go +++ b/backend/internal/handler/admin_handler.go @@ -1,22 +1,51 @@ package handler import ( + "baron-sso-backend/internal/service" "runtime" "time" "github.com/gofiber/fiber/v2" ) -type AdminHandler struct{} +type AdminHandler struct { + Keto service.KetoService +} -func NewAdminHandler() *AdminHandler { - return &AdminHandler{} +func NewAdminHandler(keto service.KetoService) *AdminHandler { + return &AdminHandler{Keto: keto} } func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error { return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"}) } +func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error { + namespace := c.Query("namespace") + object := c.Query("object") + relation := c.Query("relation") + subject := c.Query("subject") + + if namespace == "" || object == "" || relation == "" || subject == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"}) + } + + allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "allowed": allowed, + "query": fiber.Map{ + "namespace": namespace, + "object": object, + "relation": relation, + "subject": subject, + }, + }) +} + // GetSystemStats returns runtime statistics for monitoring func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error { var m runtime.MemStats diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 87d9b16f..ac232a11 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -166,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Set for audit logging + if tid, ok := client.Metadata["tenant_id"].(string); ok { + c.Locals("tenant_id", tid) + } + summary := h.mapClientSummary(*client) return c.JSON(clientDetailResponse{ Client: summary, @@ -239,6 +244,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"}) } + // Set for audit logging + c.Locals("tenant_id", targetTenantID) + // Validate Permission isAllowed := false if profile.Role == domain.RoleSuperAdmin { @@ -371,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Set for audit logging + if tid, ok := current.Metadata["tenant_id"].(string); ok { + c.Locals("tenant_id", tid) + } + clientType := "" if req.Type != nil { clientType = strings.ToLower(strings.TrimSpace(*req.Type)) @@ -446,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"}) } + // Fetch first for audit log tenant_id + client, err := h.Hydra.GetClient(c.Context(), clientID) + if err == nil { + if tid, ok := client.Metadata["tenant_id"].(string); ok { + c.Locals("tenant_id", tid) + } + } + if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil { if errors.Is(err, service.ErrHydraNotFound) { return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"}) @@ -625,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) } + // Set for audit logging + if tid, ok := current.Metadata["tenant_id"].(string); ok { + c.Locals("tenant_id", tid) + } + // 3. Update Hydra current.ClientSecret = newSecret updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current) diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go index 29611b23..8a9b448a 100644 --- a/backend/internal/handler/relying_party_handler.go +++ b/backend/internal/handler/relying_party_handler.go @@ -10,10 +10,11 @@ import ( type RelyingPartyHandler struct { Service service.RelyingPartyService + UserSvc *service.KratosAdminService } -func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler { - return &RelyingPartyHandler{Service: s} +func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler { + return &RelyingPartyHandler{Service: s, UserSvc: userSvc} } func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { @@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } + +func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error { + clientID := c.Params("id") + subjects, err := h.Service.ListOwners(c.Context(), clientID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + type ownerInfo struct { + Subject string `json:"subject"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Type string `json:"type"` // "user" or "group" + } + + owners := make([]ownerInfo, 0, len(subjects)) + for _, s := range subjects { + info := ownerInfo{Subject: s, Type: "unknown"} + if len(s) > 5 && s[:5] == "User:" { + info.Type = "user" + userID := s[5:] + identity, err := h.UserSvc.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + info.Name, _ = identity.Traits["name"].(string) + info.Email, _ = identity.Traits["email"].(string) + } + } else if len(s) > 10 && s[:10] == "UserGroup:" { + info.Type = "group" + // Group name enrichment could be added if we have a GroupService here + } + owners = append(owners, info) + } + + return c.JSON(owners) +} + +func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error { + clientID := c.Params("id") + subject := c.Params("subject") // e.g. "User:uuid" + + if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "owner added"}) +} + +func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error { + clientID := c.Params("id") + subject := c.Params("subject") + + if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "owner removed"}) +} diff --git a/backend/internal/handler/tenant_group_handler.go b/backend/internal/handler/tenant_group_handler.go index bcf4568c..5f21df88 100644 --- a/backend/internal/handler/tenant_group_handler.go +++ b/backend/internal/handler/tenant_group_handler.go @@ -9,11 +9,12 @@ import ( ) type TenantGroupHandler struct { - Service service.TenantGroupService + Service service.TenantGroupService + UserService *service.KratosAdminService } -func NewTenantGroupHandler(svc service.TenantGroupService) *TenantGroupHandler { - return &TenantGroupHandler{Service: svc} +func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler { + return &TenantGroupHandler{Service: svc, UserService: userSvc} } type tenantGroupSummary struct { @@ -120,6 +121,59 @@ func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) 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 { diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index 01cede71..89d43663 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -15,10 +15,11 @@ type TenantHandler struct { DB *gorm.DB Service service.TenantService Keto service.KetoService + UserSvc *service.KratosAdminService } -func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService) *TenantHandler { - return &TenantHandler{DB: db, Service: svc, Keto: keto} +func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler { + return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc} } type tenantSummary struct { @@ -327,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { + tenantID := c.Params("id") + userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID) + 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.UserSvc.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 { + admins = append(admins, adminInfo{ID: uid}) + } + } + + return c.JSON(admins) +} + +func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + + if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "admin added to tenant"}) +} + +func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + + if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"message": "admin removed from tenant"}) +} + func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go index 636918b7..94ec851b 100644 --- a/backend/internal/service/relying_party_service.go +++ b/backend/internal/service/relying_party_service.go @@ -16,6 +16,9 @@ type RelyingPartyService interface { Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) Delete(ctx context.Context, clientID string) error CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error) + AddOwner(ctx context.Context, clientID, subject string) error + RemoveOwner(ctx context.Context, clientID, subject string) error + ListOwners(ctx context.Context, clientID string) ([]string, error) } type relyingPartyService struct { @@ -163,6 +166,27 @@ func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clien return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation) } +func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error { + return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject) +} + +func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error { + return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject) +} + +func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) { + tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "") + if err != nil { + return nil, err + } + + subjects := make([]string, 0, len(tuples)) + for _, t := range tuples { + subjects = append(subjects, t.SubjectID) + } + return subjects, nil +} + func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty { if client == nil { return nil diff --git a/backend/internal/service/tenant_group_service.go b/backend/internal/service/tenant_group_service.go index d295111a..bafd32bc 100644 --- a/backend/internal/service/tenant_group_service.go +++ b/backend/internal/service/tenant_group_service.go @@ -15,6 +15,9 @@ type TenantGroupService interface { 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 { @@ -92,3 +95,36 @@ func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID, } 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 +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c6a5d954..e4ea83dd 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -21,6 +21,9 @@ type TenantService interface { ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 + AddTenantAdmin(ctx context.Context, tenantID, userID string) error + RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error + ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) } type tenantService struct { @@ -208,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { return s.repo.FindBySlug(ctx, slug) } + +func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { + if s.keto == nil { + return errors.New("keto service not initialized") + } + return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) +} + +func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error { + if s.keto == nil { + return errors.New("keto service not initialized") + } + return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID) +} + +func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) { + if s.keto == nil { + return nil, errors.New("keto service not initialized") + } + tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "") + if err != nil { + return nil, err + } + + userIDs := make([]string, 0, len(tuples)) + for _, t := range tuples { + if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" { + userIDs = append(userIDs, t.SubjectID[5:]) + } + } + return userIDs, nil +}