diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 89ba6219..2117b8fe 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -8,7 +8,7 @@ import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; -import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; +import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; @@ -48,7 +48,7 @@ export const router = createBrowserRouter( element: , children: [ { index: true, element: }, - { path: "admins", element: }, + { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, ], diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index fcec20bd..df69e221 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,8 @@ +import { useQuery } from "@tanstack/react-query"; import { BadgeCheck, Building2, + ChevronDown, Key, KeyRound, LayoutDashboard, @@ -9,11 +11,13 @@ import { NotebookTabs, ShieldHalf, Sun, + User as UserIcon, Users, } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -42,6 +46,13 @@ function AppLayout() { const stored = window.localStorage.getItem("admin_theme"); return stored === "dark" ? "dark" : "light"; }); + const [isProfileOpen, setIsProfileOpen] = useState(false); + + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + enabled: auth.isAuthenticated && !auth.isLoading, + }); const handleLogout = () => { if ( @@ -59,6 +70,12 @@ function AppLayout() { } }, [auth.isLoading, auth.isAuthenticated, navigate]); + useEffect(() => { + if (auth.user?.access_token) { + window.localStorage.setItem("admin_session", auth.user.access_token); + } + }, [auth.user]); + useEffect(() => { const root = document.documentElement; root.classList.remove("light", "dark"); @@ -187,7 +204,84 @@ function AppLayout() { ? t("ui.common.theme_light", "Light") : t("ui.common.theme_dark", "Dark")} - + +
+ + + {isProfileOpen && ( + <> +
setIsProfileOpen(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setIsProfileOpen(false); + }} + role="button" + tabIndex={-1} + aria-label="Close profile menu" + /> +
+
+

+ {profile?.name || auth.user?.profile.name} +

+

+ {profile?.email || auth.user?.profile.email} +

+
+ + {t( + `ui.admin.role.${profile?.role || "user"}`, + profile?.role || "USER", + )} + +
+
+ + +
+ + )} +
+ + {t("msg.admin.session_ttl", "Session TTL: 15m admin")}
diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx index 68e299c4..0530f9e8 100644 --- a/adminfront/src/components/layout/RoleSwitcher.tsx +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -40,7 +40,7 @@ const RoleSwitcher: FC = () => { super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"), tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"), rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"), - tenant_member: t("ui.admin.role.tenant_member", "TENANT MEMBER"), + user: t("ui.admin.role.user", "TENANT MEMBER"), }; return ( @@ -105,40 +105,35 @@ const RoleSwitcher: FC = () => { marginTop: "4px", }} > - {( - [ - "super_admin", - "tenant_admin", - "rp_admin", - "tenant_member", - ] as const - ).map((role) => ( - - ))} + {(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map( + (role) => ( + + ), + )} )} diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx new file mode 100644 index 00000000..80841deb --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -0,0 +1,547 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + Crown, + Plus, + Search, + ShieldCheck, + Trash2, + UserPlus, + Users, +} from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { toast } from "sonner"; +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, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { + addTenantAdmin, + addTenantOwner, + fetchTenantAdmins, + fetchTenantOwners, + fetchUsers, + removeTenantAdmin, + removeTenantOwner, +} from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; + +type DialogMode = "owner" | "admin"; + +export function TenantAdminsAndOwnersTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + const [dialogMode, setDialogMode] = useState(null); + + if (!tenantId) return null; + + const ownersQuery = useQuery({ + queryKey: ["tenant-owners", tenantId], + queryFn: () => fetchTenantOwners(tenantId), + enabled: !!tenantId, + }); + + const adminsQuery = useQuery({ + queryKey: ["tenant-admins", tenantId], + queryFn: () => fetchTenantAdmins(tenantId), + enabled: !!tenantId, + }); + + const usersQuery = useQuery({ + queryKey: ["admin-users-search", searchTerm], + queryFn: () => fetchUsers(20, 0, searchTerm), + enabled: dialogMode !== null && searchTerm.length >= 2, + }); + + const addOwnerMutation = useMutation({ + mutationFn: (userId: string) => addTenantOwner(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + toast.success( + t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."), + ); + setSearchTerm(""); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeOwnerMutation = useMutation({ + mutationFn: (userId: string) => removeTenantOwner(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + toast.success( + t( + "msg.admin.tenants.owners.remove_success", + "소유자 권한이 회수되었습니다.", + ), + ); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const addAdminMutation = useMutation({ + mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + toast.success( + t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), + ); + setSearchTerm(""); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeAdminMutation = useMutation({ + mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + toast.success( + t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), + ); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const handleAddUser = (userId: string) => { + if (dialogMode === "owner") { + addOwnerMutation.mutate(userId); + } else if (dialogMode === "admin") { + addAdminMutation.mutate(userId); + } + }; + + const handleRemoveOwner = (userId: string, userName: string) => { + if ( + window.confirm( + t( + "msg.admin.tenants.owners.remove_confirm", + "소유자를 삭제하시겠습니까?", + { name: userName }, + ), + ) + ) { + removeOwnerMutation.mutate(userId); + } + }; + + const handleRemoveAdmin = (userId: string, userName: string) => { + if ( + window.confirm( + t( + "msg.admin.tenants.admins.remove_confirm", + "관리자를 삭제하시겠습니까?", + { name: userName }, + ), + ) + ) { + removeAdminMutation.mutate(userId); + } + }; + + const currentOwners = ownersQuery.data || []; + const currentAdmins = adminsQuery.data || []; + const searchResults = usersQuery.data?.items || []; + const isDialogOpen = dialogMode !== null; + + const dialogTitle = + dialogMode === "owner" + ? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가") + : t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가"); + + const dialogDescription = + dialogMode === "owner" + ? t( + "ui.admin.tenants.owners.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + ) + : t( + "ui.admin.tenants.admins.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + ); + + return ( +
+ {/* Owners Card */} + + +
+ + + {t("ui.admin.tenants.owners.title", "테넌트 소유자")} + + + {t( + "msg.admin.tenants.owners.subtitle", + "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", + )} + +
+ +
+ +
+ + + + + {t("ui.admin.tenants.owners.table_name", "이름")} + + + {t("ui.admin.tenants.owners.table_email", "이메일")} + + + {t("ui.admin.tenants.owners.table_actions", "액션")} + + + + + {ownersQuery.isLoading ? ( + + +
+ + + ) : currentOwners.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.owners.empty", + "등록된 소유자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentOwners.map((owner) => ( + + +
+
+ {owner.name.charAt(0)} +
+ {owner.name} +
+
+ + {owner.email} + + + + +
+ )) + )} + +
+
+
+
+ + {/* Admins Card */} + + +
+ + + {t("ui.admin.tenants.admins.title", "테넌트 관리자")} + + + {t( + "msg.admin.tenants.admins.subtitle", + "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", + )} + +
+ +
+ +
+ + + + + {t("ui.admin.tenants.admins.table_name", "이름")} + + + {t("ui.admin.tenants.admins.table_email", "이메일")} + + + {t("ui.admin.tenants.admins.table_actions", "액션")} + + + + + {adminsQuery.isLoading ? ( + + +
+ + + ) : currentAdmins.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.admins.empty", + "등록된 관리자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentAdmins.map((admin) => ( + + +
+
+ {admin.name.charAt(0)} +
+ {admin.name} +
+
+ + {admin.email} + + + + +
+ )) + )} + +
+
+
+
+ + {/* Common Dialog for adding users */} + { + if (!open) { + setDialogMode(null); + setSearchTerm(""); + } + }} + > + + + + {dialogTitle} + + {dialogDescription} + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ {searchTerm.length < 2 ? ( +
+ +

+ {t( + "ui.admin.tenants.admins.dialog_search_hint", + "검색어를 입력해 주세요.", + )} +

+
+ ) : usersQuery.isLoading ? ( +
+
+
+ ) : searchResults.length === 0 ? ( +
+ {t( + "ui.admin.tenants.admins.dialog_no_results", + "검색 결과가 없습니다.", + )} +
+ ) : ( +
+ {searchResults.map((user) => { + const isAlreadyOwner = currentOwners.some( + (o) => o.id === user.id, + ); + const isAlreadyAdmin = currentAdmins.some( + (a) => a.id === user.id, + ); + const isAlreadyMember = + dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin; + + return ( +
+
+
+ {user.name.charAt(0)} +
+
+ + {user.name} + + + {user.email} + +
+
+ +
+ ); + })} +
+ )} +
+
+ +
+
+ ); +} + +export default TenantAdminsAndOwnersTab; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx deleted file mode 100644 index 87601586..00000000 --- a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { - Plus, - Search, - ShieldCheck, - Trash2, - UserPlus, - Users, -} from "lucide-react"; -import { useState } from "react"; -import { useParams } from "react-router-dom"; -import { toast } from "sonner"; -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, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../../../components/ui/dialog"; -import { Input } from "../../../components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../../../components/ui/table"; -import { - addTenantAdmin, - fetchTenantAdmins, - fetchUsers, - removeTenantAdmin, -} from "../../../lib/adminApi"; -import { t } from "../../../lib/i18n"; - -export function TenantAdminsTab() { - const { tenantId } = useParams<{ tenantId: string }>(); - const queryClient = useQueryClient(); - const [searchTerm, setSearchTerm] = useState(""); - const [isDialogOpen, setIsAddDialogOpen] = useState(false); - - if (!tenantId) return null; - - // 현재 관리자 목록 조회 - const adminsQuery = useQuery({ - queryKey: ["tenant-admins", tenantId], - queryFn: () => fetchTenantAdmins(tenantId), - enabled: !!tenantId, - }); - - // 사용자 검색 조회 (2자 이상 입력 시) - const usersQuery = useQuery({ - queryKey: ["admin-users-search", searchTerm], - queryFn: () => fetchUsers(20, 0, searchTerm), - enabled: isDialogOpen && searchTerm.length >= 2, - }); - - const addMutation = useMutation({ - mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); - toast.success( - t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), - ); - setSearchTerm(""); - }, - onError: (err: AxiosError<{ error?: string }>) => { - toast.error( - err.response?.data?.error || - t("msg.common.error", "오류가 발생했습니다."), - ); - }, - }); - - const removeMutation = useMutation({ - mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); - toast.success( - t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), - ); - }, - onError: (err: AxiosError<{ error?: string }>) => { - toast.error( - err.response?.data?.error || - t("msg.common.error", "오류가 발생했습니다."), - ); - }, - }); - - const handleAddAdmin = (userId: string) => { - addMutation.mutate(userId); - }; - - const handleRemoveAdmin = (userId: string, userName: string) => { - if ( - window.confirm( - t( - "msg.admin.tenants.admins.remove_confirm", - "관리자를 삭제하시겠습니까?", - { name: userName }, - ), - ) - ) { - removeMutation.mutate(userId); - } - }; - - const currentAdmins = adminsQuery.data || []; - const searchResults = usersQuery.data?.items || []; - - return ( -
- - -
- - - {t("ui.admin.tenants.admins.title", "테넌트 관리자")} - - - {t( - "msg.admin.tenants.admins.subtitle", - "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", - )} - -
- - { - setIsAddDialogOpen(open); - if (!open) setSearchTerm(""); - }} - > - - - - - - - {t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")} - - - {t( - "ui.admin.tenants.admins.dialog_description", - "이름 또는 이메일로 사용자를 검색하세요.", - )} - - - -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- {searchTerm.length < 2 ? ( -
- -

- {t( - "ui.admin.tenants.admins.dialog_search_hint", - "검색어를 입력해 주세요.", - )} -

-
- ) : usersQuery.isLoading ? ( -
-
-
- ) : searchResults.length === 0 ? ( -
- {t( - "ui.admin.tenants.admins.dialog_no_results", - "검색 결과가 없습니다.", - )} -
- ) : ( -
- {searchResults.map((user) => { - const isAlreadyAdmin = currentAdmins.some( - (a) => a.id === user.id, - ); - return ( -
-
-
- {user.name.charAt(0)} -
-
- - {user.name} - - - {user.email} - -
-
- -
- ); - })} -
- )} -
-
- -
-
- - -
- - - - - {t("ui.admin.tenants.admins.table_name", "이름")} - - - {t("ui.admin.tenants.admins.table_email", "이메일")} - - - {t("ui.admin.tenants.admins.table_actions", "액션")} - - - - - {adminsQuery.isLoading ? ( - - -
- - - ) : currentAdmins.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.admins.empty", - "등록된 관리자가 없습니다.", - )} -

-
-
-
- ) : ( - currentAdmins.map((admin) => ( - - -
-
- {admin.name.charAt(0)} -
- {admin.name} -
-
- - {admin.email} - - - - -
- )) - )} - -
-
-
-
-
- ); -} - -export default TenantAdminsTab; diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index d30b96a1..44bad3fc 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -17,7 +17,7 @@ function TenantDetailPage() { }); const isFederationTab = location.pathname.includes("/federation"); - const isAdminTab = location.pathname.includes("/admins"); + const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); return ( @@ -59,7 +59,7 @@ function TenantDetailPage() { to={`/tenants/${tenantId}`} className={`px-6 py-3 text-sm font-medium transition-colors relative ${ !isFederationTab && - !isAdminTab && + !isPermissionsTab && !location.pathname.includes("/schema") && !isOrganizationTab ? "text-primary border-b-2 border-primary" @@ -79,14 +79,14 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_federation", "외부 연동")} - {t("ui.admin.tenants.detail.tab_admins", "관리자 설정")} + {t("ui.admin.tenants.detail.tab_permissions", "권한")} - + + diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index d660dbd1..f4e98948 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -287,10 +287,16 @@ function UserDetailPage() { {...register("role")} > - + + diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 9e56f38a..6438190d 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -245,7 +245,7 @@ function UserListPage() { - {t(`ui.common.role.${user.role}`, user.role)} + {t(`ui.admin.role.${user.role}`, user.role)} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 1d68ad73..77a00a2e 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -167,6 +167,21 @@ export async function removeTenantAdmin(tenantId: string, userId: string) { await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`); } +export async function fetchTenantOwners(tenantId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/owners`, + ); + return data; +} + +export async function addTenantOwner(tenantId: string, userId: string) { + await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`); +} + +export async function removeTenantOwner(tenantId: string, userId: string) { + await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`); +} + // Group Management export type GroupMember = { id: string; @@ -421,6 +436,26 @@ export async function deleteUser(userId: string) { await apiClient.delete(`/v1/admin/users/${userId}`); } +export type UserProfileResponse = { + id: string; + email: string; + name: string; + phone: string; + role: string; + department: string; + affiliationType: string; + companyCode?: string; + tenantId?: string; + metadata?: Record; + tenant?: TenantSummary; + manageableTenants?: TenantSummary[]; +}; + +export async function fetchMe() { + const { data } = await apiClient.get("/v1/user/me"); + return data; +} + // Relying Party Management export type RelyingParty = { clientId: string; diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 65edff3f..92c43ae3 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -130,6 +130,44 @@ not_found = "Tenant not found." remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' subtitle = "Subtitle" +[msg.admin.tenants.admins] +add_success = "Admin added successfully." +empty = "No admins registered." +remove_confirm = "Are you sure you want to remove admin permission for {{name}}?" +remove_success = "Admin permission revoked." +subtitle = "Users with permissions to manage this tenant's resources." +title = "Tenant Admin Settings" + +[msg.admin.tenants.owners] +add_success = "Owner added successfully." +empty = "No owners registered." +remove_confirm = "Are you sure you want to remove owner permission for {{name}}?" +remove_success = "Owner permission revoked." +subtitle = "List of owners with top-level permissions for this tenant." + +[ui.admin.tenants.admins] +add_button = "Add Admin" +already_admin = "Already Admin" +dialog_description = "Search users by name or email to grant admin permissions." +dialog_no_results = "No results found." +dialog_search_hint = "Please enter a search term." +dialog_search_placeholder = "Search users (min 2 chars)..." +dialog_title = "Add New Admin" +remove_title = "Revoke Admin Permission" +table_actions = "Actions" +table_email = "Email" +table_name = "Name" +title = "Tenant Admins" + +[ui.admin.tenants.owners] +add_button = "Add Owner" +dialog_description = "Search users by name or email to grant owner permissions." +dialog_title = "Add New Owner" +table_actions = "Actions" +table_email = "Email" +table_name = "Name" +title = "Tenant Owners" + [msg.admin.tenants.create] subtitle = "Subtitle" @@ -703,7 +741,7 @@ view_audit_logs = "View Audit Logs" rp_admin = "RP ADMIN" super_admin = "SUPER ADMIN" tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +user = "TENANT MEMBER" [ui.admin.tenants] add = "Tenant Add" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 330d9947..81bf79c1 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -161,6 +161,13 @@ remove_success = "관리자 권한이 회수되었습니다." subtitle = "이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다." title = "테넌트 관리자 설정" +[msg.admin.tenants.owners] +add_success = "소유자가 성공적으로 추가되었습니다." +empty = "등록된 소유자가 없습니다." +remove_confirm = "{{name}} 사용자의 소유자 권한을 회수할까요?" +remove_success = "소유자 권한이 회수되었습니다." +subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다." + [ui.admin.tenants.admins] add_button = "관리자 추가" already_admin = "이미 관리자" @@ -175,6 +182,15 @@ table_email = "이메일" table_name = "이름" title = "테넌트 관리자" +[ui.admin.tenants.owners] +add_button = "소유자 추가" +dialog_description = "이름 또는 이메일로 사용자를 검색하여 소유자 권한을 부여하세요." +dialog_title = "새 소유자 추가" +table_actions = "액션" +table_email = "이메일" +table_name = "이름" +title = "테넌트 소유자" + [msg.admin.tenants.create] subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." @@ -772,10 +788,10 @@ policy_gate = "정책 게이트" total_tenants = "전체 테넌트" [ui.admin.role] -rp_admin = "RP ADMIN" -super_admin = "SUPER ADMIN" -tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +rp_admin = "서비스 관리자 (RP Admin)" +super_admin = "시스템 관리자 (Super Admin)" +tenant_admin = "테넌트 관리자 (Tenant Admin)" +user = "일반 사용자 (Tenant Member)" [ui.admin.tenants] add = "테넌트 추가" diff --git a/adminfront/tests/auth.spec.ts b/adminfront/tests/auth.spec.ts index 46daaaa8..8de9195f 100644 --- a/adminfront/tests/auth.spec.ts +++ b/adminfront/tests/auth.spec.ts @@ -19,6 +19,18 @@ test.describe("Authentication", () => { }); }, ); + + // Default mock for user profile + await page.route("**/api/v1/user/me", async (route) => { + await route.fulfill({ + json: { + id: "admin-user", + name: "Admin User", + email: "admin@example.com", + role: "super_admin", + }, + }); + }); }); test("should redirect unauthorized users to login page", async ({ page }) => { @@ -74,13 +86,17 @@ test.describe("Authentication", () => { }); await page.goto("/"); - await expect(page.locator("aside")).toBeVisible(); + + // Wait for the auth loading to finish + await expect(page.locator(".animate-spin")).not.toBeVisible(); // Mock window.confirm page.on("dialog", (dialog) => dialog.accept()); - // Click logout button (label: ui.admin.nav.logout) - await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); + // Click logout button in the sidebar (use nav container to be specific) + await page.click( + 'nav button:has-text("Logout"), nav button:has-text("로그아웃")', + ); await expect(page).toHaveURL(/\/login/); }); diff --git a/adminfront/tests/owners.spec.ts b/adminfront/tests/owners.spec.ts new file mode 100644 index 00000000..91ac4597 --- /dev/null +++ b/adminfront/tests/owners.spec.ts @@ -0,0 +1,139 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Tenant Owners Management", () => { + test.beforeEach(async ({ page }) => { + // Authenticate + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", + }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + // Mock OIDC config to avoid redirects + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }, + ); + + // Mock user profile + await page.route("**/api/v1/user/me", async (route) => { + await route.fulfill({ + json: { + id: "admin-user", + name: "Admin User", + email: "admin@example.com", + role: "super_admin", + }, + }); + }); + + // Mock tenant details + await page.route("**/api/v1/admin/tenants/tenant-1**", async (route) => { + await route.fulfill({ + json: { + id: "tenant-1", + name: "Test Tenant", + slug: "test-tenant", + status: "active", + type: "COMPANY", + }, + }); + }); + }); + + test("should list tenant owners", async ({ page }) => { + // Mock owners list + await page.route( + "**/api/v1/admin/tenants/tenant-1/owners**", + async (route) => { + await route.fulfill({ + json: [ + { id: "owner-1", name: "Owner One", email: "owner1@example.com" }, + ], + }); + }, + ); + + // Mock admins list (empty) + await page.route( + "**/api/v1/admin/tenants/tenant-1/admins**", + async (route) => { + await route.fulfill({ json: [] }); + }, + ); + + await page.goto("/tenants/tenant-1/permissions"); + + // Check if the page title and the owner are visible + await expect(page.getByText("테넌트 소유자")).toBeVisible(); + await expect(page.locator("table").first()).toContainText("Owner One"); + await expect(page.locator("table").first()).toContainText( + "owner1@example.com", + ); + }); + + test("should add a new owner", async ({ page }) => { + // Mock owners list (initially empty) + await page.route( + "**/api/v1/admin/tenants/tenant-1/owners**", + async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ json: [] }); + } else if (route.request().method() === "POST") { + await route.fulfill({ status: 200 }); + } + }, + ); + + // Mock admins list (empty) + await page.route( + "**/api/v1/admin/tenants/tenant-1/admins**", + async (route) => { + await route.fulfill({ json: [] }); + }, + ); + + // Mock users search + await page.route("**/api/v1/admin/users?**", async (route) => { + await route.fulfill({ + json: { + items: [ + { id: "user-2", name: "User Two", email: "user2@example.com" }, + ], + total: 1, + }, + }); + }); + + await page.goto("/tenants/tenant-1/permissions"); + + // Click add button + await page.click('button:has-text("소유자 추가")'); + + // Search for user + await page.fill('input[placeholder*="사용자 검색"]', "User Two"); + + // Wait for results and add - using a more specific selector to target the button in the dialog + const addButton = page + .locator("role=dialog") + .getByRole("button", { name: "추가" }); + await addButton.click(); + + // Verify toast or mutation (in a real app, the list would refresh) + // Here we just check if the dialog was closed or toast appears + // toast is shown on success + }); +}); diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts index 817f285a..f8988375 100644 --- a/adminfront/tests/tenants.spec.ts +++ b/adminfront/tests/tenants.spec.ts @@ -28,6 +28,18 @@ test.describe("Tenants Management", () => { }, ); + // Mock user profile + await page.route("**/api/v1/user/me", async (route) => { + await route.fulfill({ + json: { + id: "admin-user", + name: "Admin User", + email: "admin@example.com", + role: "super_admin", + }, + }); + }); + // Default mock for tenants to avoid proxy leaks await page.route("**/api/v1/admin/tenants**", async (route) => { if (route.request().method() === "GET") { diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 86b11862..e5c4a894 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -14,6 +14,7 @@ import ( "fmt" "log" "log/slog" + "net/url" "os" "strconv" "strings" @@ -346,12 +347,41 @@ func main() { app.Use(middleware.ErrorCodeEnricher()) allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") - allowCredentials := allowedOrigins != "*" + userfrontURL := getEnv("USERFRONT_URL", "http://sso.hmac.kr") + baseDomain := "" + if u, err := url.Parse(userfrontURL); err == nil { + baseDomain = u.Hostname() + } + app.Use(cors.New(cors.Config{ - AllowOrigins: allowedOrigins, - AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowOriginsFunc: func(origin string) bool { + // 1. Check static allowed list + for _, allowed := range strings.Split(allowedOrigins, ",") { + if origin == strings.TrimSpace(allowed) { + return true + } + } + + // Parse origin URL + u, err := url.Parse(origin) + if err != nil { + return false + } + hostname := u.Hostname() + + // 2. Check subdomains of base domain + if baseDomain != "" && (hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)) { + return true + } + + // 3. Check registered tenant domains + // Use context.Background() as we don't have request context here easily + allowed, _ := tenantService.IsDomainAllowed(context.Background(), hostname) + return allowed + }, + AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Test-Role, X-Mock-Role, X-Tenant-ID", AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS", - AllowCredentials: allowCredentials, + AllowCredentials: true, })) // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 @@ -483,6 +513,11 @@ func main() { // Public Tenant Registration api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) + // Tenant Context Middleware (identifies tenant from Host header) + api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{ + TenantService: tenantService, + })) + // Auth Proxy Routes auth := api.Group("/auth") auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) @@ -491,21 +526,13 @@ func main() { auth.Post("/login/code/verify", authHandler.VerifyLoginCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/password/login", authHandler.PasswordLogin) + auth.Get("/tenant-info", authHandler.GetTenantInfo) auth.Get("/consent", authHandler.GetConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/consent/reject", authHandler.RejectConsentRequest) auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest) - auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) - auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink) - auth.Post("/magic-link/verify", authHandler.VerifyMagicLink) - auth.Post("/login/code/verify", authHandler.VerifyLoginCode) - auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) - auth.Post("/password/login", authHandler.PasswordLogin) - auth.Get("/consent", authHandler.GetConsentRequest) - auth.Post("/consent/accept", authHandler.AcceptConsentRequest) - auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset) // [Changed] Use Interstitial Page for GET to prevent Scanner consumption auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) @@ -557,8 +584,8 @@ func main() { admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) - // Tenant Management (Super Admin Only) - admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) + // Tenant Management (Mixed roles, handler filters results) + admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants) admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant) admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant) @@ -567,6 +594,9 @@ func main() { 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) + admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners) + admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner) + admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner) // Organization & Org-Chart Management (Tenant Admin/Super Admin) org := admin.Group("/tenants/:tenantId/organization", requireAdmin) diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index e4bad4e8..7360afa7 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil) + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "") if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 4d195458..d0d63baa 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -538,6 +538,55 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string { return parts[1] } +func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string { + // 1. Try to use the Host header from the request + host := c.Get("X-Forwarded-Host") + if host == "" { + host = c.Hostname() + } + + // 2. Determine scheme + scheme := "https" + if os.Getenv("APP_ENV") == "dev" || os.Getenv("APP_ENV") == "" || c.Protocol() == "http" { + scheme = "http" + } + + // 3. Fallback to env if host is not available or is localhost (and not in dev) + envURL := os.Getenv("USERFRONT_URL") + if envURL == "" { + envURL = "http://sso.hmac.kr" + } + + if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") { + return strings.TrimRight(envURL, "/") + } + + return fmt.Sprintf("%s://%s", scheme, host) +} + +func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error { + tenantID, _ := c.Locals("tenant_id").(string) + if tenantID == "" { + return c.JSON(fiber.Map{ + "isCentral": true, + }) + } + + tenant, err := h.TenantService.GetTenant(c.Context(), tenantID) + if err != nil { + return errorJSON(c, fiber.StatusNotFound, "Tenant not found") + } + + return c.JSON(fiber.Map{ + "isCentral": false, + "id": tenant.ID, + "name": tenant.Name, + "slug": tenant.Slug, + "description": tenant.Description, + "type": tenant.Type, + }) +} + // normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다. func normalizePhoneForLoginID(phone string) string { normalized := strings.ReplaceAll(phone, "-", "") @@ -920,10 +969,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusNotFound, "User not registered") } - userfrontURL := os.Getenv("USERFRONT_URL") - if userfrontURL == "" { - userfrontURL = "http://sso.hmac.kr" - } + userfrontURL := h.resolveUserfrontURL(c) if req.URI != "" { userfrontURL = req.URI } @@ -1692,14 +1738,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured") } - userfrontURL := os.Getenv("USERFRONT_URL") - if userfrontURL == "" { - ale.Status = fiber.StatusInternalServerError - ale.LatencyMs = time.Since(startTime) - ale.ProviderError = "USERFRONT_URL is not set" - ale.Log(slog.LevelError, "USERFRONT_URL is not set") - return errorJSON(c, fiber.StatusInternalServerError, "USERFRONT_URL environment variable is not set") - } + userfrontURL := h.resolveUserfrontURL(c) // [Changed] Point to Backend API for verification (which then redirects to Frontend) redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) ale.RedirectTo = redirectURL @@ -1863,10 +1902,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error { ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId_normalized"] = loginID - userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") - if userfrontURL == "" { - userfrontURL = "https://sso.hmac.kr" - } + userfrontURL := h.resolveUserfrontURL(c) redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password") if parseErr != nil { ale.Status = fiber.StatusInternalServerError @@ -2000,10 +2036,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error { } // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. - userfrontURL := os.Getenv("USERFRONT_URL") - if userfrontURL == "" { - userfrontURL = "https://sso.hmac.kr" - } + userfrontURL := h.resolveUserfrontURL(c) qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef) slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload) @@ -3925,104 +3958,85 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { } func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { - slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK", - "APP_ENV", os.Getenv("APP_ENV"), - "GO_ENV", os.Getenv("GO_ENV"), - "X-Test-Role", c.Get("X-Test-Role"), - ) - slog.Info("🚀 [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method()) - // [Dev Only] Mock Role Bypass appEnv := strings.ToLower(os.Getenv("APP_ENV")) + isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" + mockRole := c.Get("X-Test-Role") if mockRole == "" { mockRole = c.Get("X-Mock-Role") } - // Always log in development to see what's happening - if appEnv == "dev" || appEnv == "development" || appEnv == "" { - slog.Info("🔍 [AUTH_DEBUG] Checking mock role", - "env", appEnv, - "mockRole", mockRole, - "X-Test-Role", c.Get("X-Test-Role"), - "X-Mock-Role", c.Get("X-Mock-Role"), - ) + token := h.getBearerToken(c) + cookie := c.Get("Cookie") + + var profile *domain.UserProfileResponse + var err error + cacheKey := "" + + // 1. Try to fetch real profile if token/cookie exists + if token != "" || cookie != "" { + // Try Redis Cache + if h.RedisService != nil && token != "" { + cacheKey = "cache:profile:token:" + token + cached, _ := h.RedisService.Get(cacheKey) + if cached != "" { + if json.Unmarshal([]byte(cached), &profile) == nil { + slog.Debug("Profile loaded from cache", "token", token[:10]+"...", "role", profile.Role) + } + } + } + + if profile == nil { + // Fetch from Kratos (SoT) + if token != "" { + profile, err = h.getKratosProfile(token) + if err != nil && h.Hydra != nil { + // Fallback to Hydra introspection + slog.Debug("Kratos session check failed, trying Hydra", "error", err) + profile, err = h.getHydraProfile(c.Context(), token) + } + } else if cookie != "" { + profile, err = h.getKratosProfileWithCookie(cookie) + } + } } - // If in dev mode and we have a mock role, bypass Kratos - if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" { - slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole) - mockProfile := &domain.UserProfileResponse{ + // 2. Role Override for real profile or fallback to Mock Profile + if profile != nil { + if isDev && mockRole != "" { + slog.Info("🔑 [AUTH] Overriding real profile role", + "email", profile.Email, "originalRole", profile.Role, "overriddenRole", mockRole) + profile.Role = mockRole + } + } else if isDev && mockRole != "" && token == "" && cookie == "" { + slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "role", mockRole) + profile = &domain.UserProfileResponse{ ID: "00000000-0000-0000-0000-000000000000", Email: "mock@hmac.kr", Name: "Dev Mock User", Role: mockRole, } if tid := c.Get("X-Tenant-ID"); tid != "" { - mockProfile.TenantID = &tid - } - return mockProfile, nil - } - - // Mock bypass failed - log headers for debugging if in dev - if appEnv == "dev" || appEnv == "development" || appEnv == "" { - slog.Warn("⚠️ [DEBUG] Mock auth bypass failed", - "appEnv", appEnv, - "X-Test-Role", c.Get("X-Test-Role"), - "X-Mock-Role", c.Get("X-Mock-Role"), - "path", c.Path()) - } - - var profile *domain.UserProfileResponse - var err error - - token := h.getBearerToken(c) - cookie := c.Get("Cookie") - cacheKey := "" - - // 1. Try Redis Cache - if h.RedisService != nil { - if token != "" { - cacheKey = "cache:profile:token:" + token - } - // Cookie based caching skipped for simplicity/safety - - if cacheKey != "" { - cached, _ := h.RedisService.Get(cacheKey) - if cached != "" { - if json.Unmarshal([]byte(cached), &profile) == nil { - return profile, nil - } - } + profile.TenantID = &tid } } - // 2. Fetch from Kratos (SoT) - if token != "" { - profile, err = h.getKratosProfile(token) - } else { - if cookie != "" { - profile, err = h.getKratosProfileWithCookie(cookie) - } - } - - if err != nil || profile == nil { + if profile == nil { + slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole) return nil, errors.New("invalid session (trace:resolve_profile)") } // 3. Post-Process (Defaults & Metadata Enrichment) - // Default Role if missing (migration safety) if profile.Role == "" { profile.Role = domain.RoleUser } // Fetch Tenant Metadata if missing - // Case A: Have TenantID from Kratos -> Fetch by ID if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { profile.Tenant = tenant } } - // Case B: Have CompanyCode but no TenantID -> Fetch by Slug if profile.Tenant == nil && profile.CompanyCode != "" { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { profile.Tenant = tenant @@ -4033,7 +4047,10 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe } // 4. Save to Redis Cache (Short TTL) - if h.RedisService != nil && cacheKey != "" { + // IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key + // or we should include the mock role in the cache key. + // For simplicity, let's skip caching if mockRole is present in dev. + if h.RedisService != nil && cacheKey != "" && err == nil && !(isDev && mockRole != "") { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL @@ -5060,12 +5077,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string] return nil } -func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentity(sessionToken) +func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) { + intro, err := h.Hydra.IntrospectToken(ctx, token) if err != nil { + slog.Error("Hydra introspection failed", "error", err) return nil, err } + if !intro.Active { + slog.Warn("Hydra token is not active") + return nil, errors.New("token is not active") + } + slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) + + // Fetch identity details from Kratos by subject (identityID) + identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject) + if err != nil || identity == nil { + slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject) + // Fallback to minimal profile if Kratos identity not found + return &domain.UserProfileResponse{ + ID: intro.Subject, + Email: "unknown@hydra.local", + Name: "Hydra User", + Role: domain.RoleUser, + }, nil + } + + return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil +} + +func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse { email, _ := traits["email"].(string) name, _ := traits["name"].(string) phone, _ := traits["phone_number"].(string) @@ -5101,8 +5142,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile profile.Metadata[k] = v } } + return profile +} - return profile, nil +func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentity(sessionToken) + if err != nil { + return nil, err + } + return h.mapKratosIdentityToProfile(identityID, traits), nil } func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { @@ -5110,44 +5158,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro if err != nil { return nil, err } - - email, _ := traits["email"].(string) - name, _ := traits["name"].(string) - phone, _ := traits["phone_number"].(string) - dept, _ := traits["department"].(string) - affType, _ := traits["affiliationType"].(string) - compCode, _ := traits["companyCode"].(string) - role, _ := traits["role"].(string) - tenantID, _ := traits["tenant_id"].(string) - - profile := &domain.UserProfileResponse{ - ID: identityID, - Email: email, - Name: name, - Phone: h.formatPhoneForDisplay(phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - Role: role, - Metadata: make(map[string]any), - } - - if tenantID != "" { - profile.TenantID = &tenantID - } - - coreTraits := map[string]bool{ - "email": true, "name": true, "phone_number": true, - "grade": true, "companyCode": true, "department": true, - "affiliationType": true, "role": true, "tenant_id": true, - } - for k, v := range traits { - if !coreTraits[k] { - profile.Metadata[k] = v - } - } - - return profile, nil + return h.mapKratosIdentityToProfile(identityID, traits), nil } // UpdateMe - Updates current user's profile with phone verification check diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 35c13cf3..00212e60 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -140,7 +140,7 @@ type AsyncMockTenantService struct { mock.Mock } -func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { return nil, nil } @@ -171,6 +171,9 @@ func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { return nil, nil } +func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { + return false, nil +} func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil } func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index aa7b7a66..56cf1bc4 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -109,9 +109,36 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error { offset = 0 } - tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId) - if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + var tenants []domain.Tenant + var total int64 + var err error + + profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + + // If Tenant Admin, only list manageable tenants + if profile != nil && profile.Role == domain.RoleTenantAdmin { + slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID) + tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID) + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error()) + } + total = int64(len(tenants)) + // Apply basic pagination if needed (optional for usually small number of manageable tenants) + if offset < len(tenants) { + end := offset + limit + if end > len(tenants) { + end = len(tenants) + } + tenants = tenants[offset:end] + } else { + tenants = []domain.Tenant{} + } + } else { + // Super Admin case + tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } } // Fetch member counts for all tenants in one query using slugs (company codes) @@ -221,7 +248,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { parentID = &pid } - tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID) + // Extract creator ID if present + creatorID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + creatorID = profile.ID + } + + tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID) if err != nil { if strings.Contains(err.Error(), "already exists") { return errorJSON(c, fiber.StatusConflict, err.Error()) @@ -423,20 +456,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { } userID := strings.TrimPrefix(rel.SubjectID, "User:") - // Fetch user details from Kratos - identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) - if err != nil { - admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"}) - continue - } + // Fetch user details - Try Kratos first, then local DB + name := "Unknown" + email := "Unknown" - name := "" - if n, ok := identity.Traits["name"].(string); ok { - name = n - } - email := "" - if e, ok := identity.Traits["email"].(string); ok { - email = e + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + } else if h.UserRepo != nil { + // Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos) + user, err := h.UserRepo.FindByID(c.Context(), userID) + if err == nil && user != nil { + name = user.Name + email = user.Email + } else if userID == "00000000-0000-0000-0000-000000000000" { + name = "Dev Mock User" + email = "mock@hmac.kr" + } } admins = append(admins, adminInfo{ @@ -464,6 +505,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) + // Also add as member for UI visibility/ReBAC logic + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "members", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) } return c.SendStatus(fiber.StatusOK) @@ -489,6 +538,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +func (h *TenantHandler) ListOwners(c *fiber.Ctx) error { + tenantID := c.Params("id") + if tenantID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") + } + + // Fetch owners from Keto + relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + type ownerInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + owners := []ownerInfo{} + + for _, rel := range relations { + if !strings.HasPrefix(rel.SubjectID, "User:") { + continue + } + userID := strings.TrimPrefix(rel.SubjectID, "User:") + + // Fetch user details - Try Kratos first, then local DB + name := "Unknown" + email := "Unknown" + + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + } else if h.UserRepo != nil { + // Fallback to local DB + user, err := h.UserRepo.FindByID(c.Context(), userID) + if err == nil && user != nil { + name = user.Name + email = user.Email + } else if userID == "00000000-0000-0000-0000-000000000000" { + name = "Dev Mock User" + email = "mock@hmac.kr" + } + } + + owners = append(owners, ownerInfo{ + ID: userID, + Name: name, + Email: email, + }) + } + + return c.JSON(owners) +} + +func (h *TenantHandler) AddOwner(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "owners", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + // Also add as member for UI visibility/ReBAC logic + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "members", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "owners", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } + + return c.SendStatus(fiber.StatusNoContent) +} + func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 80e89d73..f6e785ca 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -21,8 +21,8 @@ type MockTenantService struct { mock.Mock } -func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { - args := m.Called(ctx, name, slug, tenantType, description, domains, parentID) +func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -71,6 +71,19 @@ func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2) } +func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + args := m.Called(ctx, userID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]domain.Tenant), args.Error(1) +} + +func (m *MockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { + args := m.Called(ctx, domainName) + return args.Bool(0), args.Error(1) +} + func (m *MockTenantService) SetKetoService(keto service.KetoService) { m.Called(keto) } @@ -133,7 +146,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) { } body, _ := json.Marshal(input) - mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)). + mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), ""). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 0175974b..aaa345ab 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -59,15 +59,15 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. c.Locals("tenant_id", objectID) } - // Check with Keto - allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) + // Check with Keto - add User: prefix to subject + allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation) if err != nil { slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) return errorJSON(c, fiber.StatusInternalServerError, "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, "userRole", profile.Role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role")) return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID) } @@ -111,13 +111,11 @@ func RequireRole(config RBACConfig) fiber.Handler { "userRole", profile.Role, "allowedRoles", config.AllowedRoles, "path", c.Path(), + "X-Test-Role", c.Get("X-Test-Role"), ) return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions") } - // Store profile in locals for further use in handlers - c.Locals("user_profile", profile) - return c.Next() } } diff --git a/backend/internal/middleware/rbac_test.go b/backend/internal/middleware/rbac_test.go index db9ff925..54bd4b9d 100644 --- a/backend/internal/middleware/rbac_test.go +++ b/backend/internal/middleware/rbac_test.go @@ -123,7 +123,7 @@ func TestRequireKetoPermission_Success(t *testing.T) { profile := &domain.UserProfileResponse{ID: "user1", Role: "user"} mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil) - mockKeto.On("CheckPermission", mock.Anything, "user1", "tenants", "tenant1", "read").Return(true, nil) + mockKeto.On("CheckPermission", mock.Anything, "User:user1", "tenants", "tenant1", "read").Return(true, nil) app.Get("/tenants/:id", RequireKetoPermission(config, "tenants", "read"), func(c *fiber.Ctx) error { return c.SendString("ok") diff --git a/backend/internal/middleware/tenant_middleware.go b/backend/internal/middleware/tenant_middleware.go new file mode 100644 index 00000000..80eb346f --- /dev/null +++ b/backend/internal/middleware/tenant_middleware.go @@ -0,0 +1,69 @@ +package middleware + +import ( + "baron-sso-backend/internal/service" + "log/slog" + "net/url" + "os" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// TenantContextConfig defines the configuration for Tenant context middleware +type TenantContextConfig struct { + TenantService service.TenantService +} + +// TenantContextMiddleware identifies the tenant based on the Host header (subdomain or custom domain) +func TenantContextMiddleware(config TenantContextConfig) fiber.Handler { + userfrontURL := os.Getenv("USERFRONT_URL") + baseDomain := "" + if userfrontURL != "" { + if u, err := url.Parse(userfrontURL); err == nil { + baseDomain = u.Hostname() + } + } + + return func(c *fiber.Ctx) error { + hostname := c.Hostname() + if hostname == "" { + hostname = string(c.Request().Header.Host()) + } + + if hostname == "" { + return c.Next() + } + + // 1. If it's the exact base domain or localhost, no specific tenant context from host + if hostname == baseDomain || hostname == "localhost" || hostname == "127.0.0.1" { + return c.Next() + } + + // 2. Try to find by registered custom domain + tenant, err := config.TenantService.GetTenantByDomain(c.Context(), hostname) + if err == nil && tenant != nil { + slog.Debug("Tenant identified by custom domain", "hostname", hostname, "tenantID", tenant.ID) + c.Locals("tenant_id", tenant.ID) + c.Locals("tenant_slug", tenant.Slug) + return c.Next() + } + + // 3. Try to find by subdomain (slug.baseDomain) + if baseDomain != "" && strings.HasSuffix(hostname, "."+baseDomain) { + slug := strings.TrimSuffix(hostname, "."+baseDomain) + // Handle cases like "www.sso.hmac.kr" if baseDomain is "sso.hmac.kr" + if slug != "" && slug != "www" { + tenant, err := config.TenantService.GetTenantBySlug(c.Context(), slug) + if err == nil && tenant != nil { + slog.Debug("Tenant identified by subdomain slug", "slug", slug, "tenantID", tenant.ID) + c.Locals("tenant_id", tenant.ID) + c.Locals("tenant_slug", tenant.Slug) + return c.Next() + } + } + } + + return c.Next() + } +} diff --git a/backend/internal/middleware/tenant_middleware_test.go b/backend/internal/middleware/tenant_middleware_test.go new file mode 100644 index 00000000..b3d1cd2e --- /dev/null +++ b/backend/internal/middleware/tenant_middleware_test.go @@ -0,0 +1,110 @@ +package middleware + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "net/http/httptest" + "os" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockTenantServiceForMiddleware struct { + mock.Mock +} + +func (m *MockTenantServiceForMiddleware) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) { + args := m.Called(mock.Anything, emailDomain) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantServiceForMiddleware) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) { + args := m.Called(mock.Anything, slug) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Tenant), args.Error(1) +} + +func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) { + return nil, 0, nil +} + +func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) { + return nil, nil +} + +func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { + return false, nil +} + +func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error { + return nil +} +func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {} + +func TestTenantContextMiddleware(t *testing.T) { + os.Setenv("USERFRONT_URL", "https://sso.hmac.kr") + defer os.Unsetenv("USERFRONT_URL") + + mockSvc := new(MockTenantServiceForMiddleware) + app := fiber.New() + app.Use(TenantContextMiddleware(TenantContextConfig{TenantService: mockSvc})) + + app.Get("/test", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "tenant_id": c.Locals("tenant_id"), + "tenant_slug": c.Locals("tenant_slug"), + }) + }) + + t.Run("Base Domain - No Tenant Context", func(t *testing.T) { + req := httptest.NewRequest("GET", "http://sso.hmac.kr/test", nil) + req.Host = "sso.hmac.kr" + resp, _ := app.Test(req) + assert.Equal(t, 200, resp.StatusCode) + mockSvc.AssertNotCalled(t, "GetTenantByDomain", mock.Anything, "sso.hmac.kr") + }) + + t.Run("Subdomain - Identify by Slug", func(t *testing.T) { + mockSvc.On("GetTenantByDomain", mock.Anything, "tenant1.sso.hmac.kr").Return(nil, nil).Once() + mockSvc.On("GetTenantBySlug", mock.Anything, "tenant1").Return(&domain.Tenant{ID: "t1", Slug: "tenant1"}, nil).Once() + + req := httptest.NewRequest("GET", "/test", nil) + req.Host = "tenant1.sso.hmac.kr" + req.Header.Set("Host", "tenant1.sso.hmac.kr") + resp, _ := app.Test(req) + assert.Equal(t, 200, resp.StatusCode) + mockSvc.AssertExpectations(t) + }) + + t.Run("Custom Domain - Identify by Domain", func(t *testing.T) { + mockSvc.On("GetTenantByDomain", mock.Anything, "company.com").Return(&domain.Tenant{ID: "t2", Slug: "company"}, nil).Once() + + req := httptest.NewRequest("GET", "/test", nil) + req.Host = "company.com" + req.Header.Set("Host", "company.com") + resp, _ := app.Test(req) + assert.Equal(t, 200, resp.StatusCode) + mockSvc.AssertExpectations(t) + }) +} diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index b86ebaba..6bafc76c 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } + +type HydraIntrospectionResponse struct { + Active bool `json:"active"` + Subject string `json:"sub"` + ClientID string `json:"client_id"` + Scope string `json:"scope"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Ext map[string]interface{} `json:"ext"` +} + +func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) { + endpoint := fmt.Sprintf("%s/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) + form := url.Values{} + form.Set("token", token) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("hydra admin: introspection failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var res HydraIntrospectionResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c1c161a5..ff104c91 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -13,12 +13,14 @@ import ( ) type TenantService interface { - RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) + RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) + ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) + IsDomainAllowed(ctx context.Context, domainName string) (bool, error) ApproveTenant(ctx context.Context, id string) error SetKetoService(keto KetoService) // 추가 } @@ -90,7 +92,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string return s.repo.FindByIDs(ctx, allIDs) } -func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) @@ -119,15 +121,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy return nil, err } - // [Keto] Sync hierarchy via Outbox if ParentID exists - if s.outboxRepo != nil && tenant.ParentID != nil { - _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenant.ID, - Relation: "parents", - Subject: "Tenant:" + *tenant.ParentID, - Action: domain.KetoOutboxActionCreate, - }) + // [Keto] Sync hierarchy and ownership via Outbox + if s.outboxRepo != nil { + // Sync hierarchy + if tenant.ParentID != nil { + if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "parents", + Subject: "Tenant:" + *tenant.ParentID, + Action: domain.KetoOutboxActionCreate, + }); err != nil { + slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err) + } + } + + // Sync creator ownership + if creatorID != "" { + slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID) + // Add as owner + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "owners", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + // Add as admin + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "admins", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + // Add as member + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "members", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + } } // 3. Add Domains (Auto-verify for manual admin registration) @@ -187,12 +223,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { // [Keto] Sync relation via Outbox if s.outboxRepo != nil { if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { - slog.Info("Queueing tenant admin sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) + slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) // Check if user already exists in our Read-Model if s.userRepo != nil { user, err := s.userRepo.FindByEmail(ctx, adminEmail) if err == nil && user != nil { - // User exists, assign Admin role in Keto via Outbox + // User exists, assign Admin, Owner, and Member roles in Keto via Outbox + slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID) + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "owners", + Subject: "User:" + user.ID, + Action: domain.KetoOutboxActionCreate, + }) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, @@ -200,6 +244,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { Subject: "User:" + user.ID, Action: domain.KetoOutboxActionCreate, }) + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "members", + Subject: "User:" + user.ID, + Action: domain.KetoOutboxActionCreate, + }) } else { slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail) } @@ -232,3 +283,14 @@ func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, pare // Let the repository handle the query and pagination return s.repo.List(ctx, limit, offset, parentID) } + +func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) { + tenant, err := s.repo.FindByDomain(ctx, domainName) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err + } + return tenant != nil && tenant.Status == domain.TenantStatusActive, nil +} diff --git a/backend/internal/service/tenant_service_edge_test.go b/backend/internal/service/tenant_service_edge_test.go index f446b11a..a2a34002 100644 --- a/backend/internal/service/tenant_service_edge_test.go +++ b/backend/internal/service/tenant_service_edge_test.go @@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) { // Mock: slug already exists mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) - tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil) + tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") assert.Nil(t, tenant) @@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) { ctx := context.Background() // Case 1: Too short - _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil) + _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) // Case 2: Invalid characters - _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil) + _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 2952bfe8..6ca1bc06 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() - tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil) + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil, "") assert.NoError(t, err) assert.NotNil(t, tenant) assert.Equal(t, "t1", tenant.ID) mockRepo.AssertExpectations(t) } +func TestTenantService_RegisterTenant_WithCreator(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + mockOutbox := new(MockKetoOutboxRepositoryShared) + svc := NewTenantService(mockRepo, nil, mockOutbox) + + ctx := context.Background() + name := "Creator Tenant" + slug := "creator-tenant" + creatorID := "creator-uuid" + tenantID := "t-new" + + mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once() + mockRepo.On("Create", ctx, mock.MatchedBy(func(t *domain.Tenant) bool { + return t.Slug == slug + })).Run(func(args mock.Arguments) { + t := args.Get(1).(*domain.Tenant) + t.ID = tenantID + }).Return(nil) + + // Expect owners sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+creatorID + })).Return(nil) + // Expect admins sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+creatorID + })).Return(nil) + // Expect members sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+creatorID + })).Return(nil) + + mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: tenantID, Slug: slug}, nil).Once() + + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", nil, nil, creatorID) + assert.NoError(t, err) + assert.NotNil(t, tenant) + mockRepo.AssertExpectations(t) + mockOutbox.AssertExpectations(t) +} + func TestTenantService_RequestRegistration_NoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) mockOutbox := new(MockKetoOutboxRepositoryShared) @@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { mockRepo.On("Update", ctx, mock.Anything).Return(nil) mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil) // Now using Outbox instead of direct Keto call + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+userID + })).Return(nil) mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID })).Return(nil) + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID + })).Return(nil) err := svc.ApproveTenant(ctx, tenantID) assert.NoError(t, err) diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 06b111bf..eabdc7fc 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -5,7 +5,7 @@ class User implements Namespace {} class Tenant implements Namespace { related: { owners: User[] - admins: (User | SubjectSet)[] + admins: User[] members: User[] parents: Tenant[] } @@ -14,12 +14,18 @@ class Tenant implements Namespace { view: (ctx: Context): boolean => this.related.members.includes(ctx.subject) || this.related.admins.includes(ctx.subject) || + this.related.owners.includes(ctx.subject) || this.related.parents.traverse((p) => p.permits.view(ctx)), manage: (ctx: Context): boolean => this.related.admins.includes(ctx.subject) || + this.related.owners.includes(ctx.subject) || this.related.parents.traverse((p) => p.permits.manage(ctx)), - + + manage_admins: (ctx: Context): boolean => + this.related.owners.includes(ctx.subject) || + this.related.parents.traverse((p) => p.permits.manage_admins(ctx)), + create_subtenant: (ctx: Context): boolean => this.permits.manage(ctx) } diff --git a/locales/en.toml b/locales/en.toml index 8d5d27f4..af2e11bd 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -182,6 +182,13 @@ remove_confirm = "Remove Confirm" remove_success = "Remove Success" subtitle = "Subtitle" +[msg.admin.tenants.owners] +add_success = "Owner added successfully." +empty = "No owners registered." +remove_confirm = "Are you sure you want to remove this owner?" +remove_success = "Owner permission revoked." +subtitle = "List of owners with top-level permissions for this tenant." + [msg.admin.tenants.create] subtitle = "Subtitle" @@ -811,7 +818,7 @@ view_audit_logs = "View Audit Logs" rp_admin = "RP ADMIN" super_admin = "SUPER ADMIN" tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +user = "TENANT MEMBER" [ui.admin.tenants] add = "Add Tenant" @@ -831,6 +838,17 @@ table_email = "Email" table_name = "Name" title = "Title" +[ui.admin.tenants.owners] +add_button = "Add Owner" +already_owner = "Already Owner" +dialog_description = "Search users by name or email." +dialog_title = "Add New Owner" +remove_title = "Revoke Owner Permission" +table_actions = "Actions" +table_email = "Email" +table_name = "Name" +title = "Tenant Owners" + [ui.admin.tenants.breadcrumb] list = "List" section = "Tenants" @@ -863,9 +881,9 @@ title = "Tenant Profile" breadcrumb_list = "Tenant List" header_subtitle = "Header Subtitle" loading = "Loading" -tab_admins = "Tab Admins" tab_federation = "Tab Federation" tab_organization = "Organization Manage" +tab_permissions = "Permissions" tab_profile = "Profile" tab_schema = "Tab Schema" title = "Details" diff --git a/locales/ko.toml b/locales/ko.toml index a0a9f2c3..abc0bfff 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -182,6 +182,13 @@ remove_confirm = "관리자를 삭제하시겠습니까?" remove_success = "권한이 회수되었습니다." subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다." +[msg.admin.tenants.owners] +add_success = "소유자가 추가되었습니다." +empty = "등록된 소유자가 없습니다." +remove_confirm = "소유자를 삭제하시겠습니까?" +remove_success = "소유자 권한이 회수되었습니다." +subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다." + [msg.admin.tenants.create] subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." @@ -811,7 +818,7 @@ view_audit_logs = "감사 로그 보기" rp_admin = "RP ADMIN" super_admin = "SUPER ADMIN" tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +user = "TENANT MEMBER" [ui.admin.tenants] add = "테넌트 추가" @@ -831,6 +838,17 @@ table_email = "이메일" table_name = "이름" title = "테넌트 관리자" +[ui.admin.tenants.owners] +add_button = "소유자 추가" +already_owner = "이미 소유자" +dialog_description = "이름 또는 이메일로 사용자를 검색하세요." +dialog_title = "새 소유자 추가" +remove_title = "소유자 권한 회수" +table_actions = "액션" +table_email = "이메일" +table_name = "이름" +title = "테넌트 소유자" + [ui.admin.tenants.breadcrumb] list = "List" section = "Tenants" @@ -863,9 +881,9 @@ title = "Tenant Profile" breadcrumb_list = "테넌트 목록" header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리합니다." loading = "불러오는 중..." -tab_admins = "관리자 설정" tab_federation = "외부 연동" tab_organization = "조직 관리" +tab_permissions = "권한" tab_profile = "프로필" tab_schema = "사용자 스키마" title = "상세" diff --git a/locales/template.toml b/locales/template.toml index f5cfd0d5..37627a41 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -734,7 +734,7 @@ view_audit_logs = "" rp_admin = "" super_admin = "" tenant_admin = "" -tenant_member = "" +user = "" [ui.admin.tenants] add = "" @@ -1479,6 +1479,13 @@ remove_confirm = "" remove_success = "" subtitle = "" +[msg.admin.tenants.owners] +add_success = "" +empty = "" +remove_confirm = "" +remove_success = "" +subtitle = "" + [msg.admin.tenants] approve_confirm = "" approve_success = "" @@ -1524,6 +1531,17 @@ table_email = "" table_name = "" title = "" +[ui.admin.tenants.owners] +add_button = "" +already_owner = "" +dialog_description = "" +dialog_title = "" +remove_title = "" +table_actions = "" +table_email = "" +table_name = "" +title = "" + [ui.admin.tenants.create.form] parent = "" type = "" @@ -1532,9 +1550,9 @@ type = "" breadcrumb_list = "" header_subtitle = "" loading = "" -tab_admins = "" tab_federation = "" tab_organization = "" +tab_permissions = "" tab_profile = "" tab_schema = "" title = ""