1
0
forked from baron/baron-sso

Merge pull request 'feature/af-is309' (#362) from feature/af-is309 into dev

Reviewed-on: baron/baron-sso#362
This commit is contained in:
2026-03-03 17:30:47 +09:00
33 changed files with 1758 additions and 604 deletions

View File

@@ -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: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],

View File

@@ -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")}
</button>
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
<div className="relative">
<button
type="button"
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-muted-foreground transition hover:bg-muted/20"
>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs uppercase">
{profile?.name?.charAt(0) || <UserIcon size={14} />}
</div>
<span className="hidden max-w-[100px] truncate font-medium md:inline-block">
{profile?.name || auth.user?.profile.name || "User"}
</span>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileOpen && (
<>
<div
className="fixed inset-0 z-30"
onClick={() => setIsProfileOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setIsProfileOpen(false);
}}
role="button"
tabIndex={-1}
aria-label="Close profile menu"
/>
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-xl border border-border bg-card p-2 shadow-xl ring-1 ring-black ring-opacity-5 focus:outline-none z-40 animate-in fade-in zoom-in-95 duration-200">
<div className="px-3 py-3 border-b border-border/50 mb-1">
<p className="text-sm font-semibold truncate">
{profile?.name || auth.user?.profile.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{profile?.email || auth.user?.profile.email}
</p>
<div className="mt-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary uppercase">
{t(
`ui.admin.role.${profile?.role || "user"}`,
profile?.role || "USER",
)}
</span>
</div>
</div>
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(
`/users/${profile?.id || auth.user?.profile.sub}`,
);
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
>
<UserIcon size={16} />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
</button>
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
handleLogout();
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-destructive transition hover:bg-destructive/10"
>
<LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</>
)}
</div>
<span className="hidden md:inline-flex rounded-full border border-border px-3 py-2 text-muted-foreground">
{t("msg.admin.session_ttl", "Session TTL: 15m admin")}
</span>
</div>

View File

@@ -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) => (
<button
key={role}
type="button"
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
))}
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
(role) => (
<button
key={role}
type="button"
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
),
)}
</div>
)}
</div>

View File

@@ -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<DialogMode | null>(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 (
<div className="space-y-8 mt-6">
{/* Owners Card */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</Button>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.owners.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={removeOwnerMutation.isPending}
title={t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Admins Card */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={removeAdminMutation.isPending}
title={t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{dialogTitle}
</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{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 (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyMember ? "ghost" : "outline"}
disabled={
isAlreadyMember ||
addOwnerMutation.isPending ||
addAdminMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyMember ? (
<Badge variant="secondary" className="font-normal">
{dialogMode === "owner"
? t(
"ui.admin.tenants.owners.already_owner",
"이미 소유자",
)
: t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default TenantAdminsAndOwnersTab;

View File

@@ -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 (
<div className="space-y-6 mt-6">
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
setIsAddDialogOpen(open);
if (!open) setSearchTerm("");
}}
>
<DialogTrigger asChild>
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyAdmin ? "ghost" : "outline"}
disabled={isAlreadyAdmin || addMutation.isPending}
onClick={() => handleAddAdmin(user.id)}
>
{isAlreadyAdmin ? (
<Badge
variant="secondary"
className="font-normal"
>
{t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent>
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={removeMutation.isPending}
title={t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}
export default TenantAdminsTab;

View File

@@ -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", "외부 연동")}
</Link>
<Link
to={`/tenants/${tenantId}/admins`}
to={`/tenants/${tenantId}/permissions`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isAdminTab
isPermissionsTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")}
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
</Link>
<Link
to={`/tenants/${tenantId}/organization`}

View File

@@ -438,10 +438,16 @@ function UserCreatePage() {
{...register("role")}
>
<option value="user">
{t("ui.common.role.user", "User")}
{t("ui.admin.role.user", "TENANT MEMBER")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
<option value="tenant_admin">
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "RP ADMIN")}
</option>
<option value="super_admin">
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
</option>
</select>
</div>

View File

@@ -287,10 +287,16 @@ function UserDetailPage() {
{...register("role")}
>
<option value="user">
{t("ui.common.role.user", "User")}
{t("ui.admin.role.user", "TENANT MEMBER")}
</option>
<option value="admin">
{t("ui.common.role.admin", "Admin")}
<option value="tenant_admin">
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "RP ADMIN")}
</option>
<option value="super_admin">
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
</option>
</select>
</div>

View File

@@ -245,7 +245,7 @@ function UserListPage() {
</TableCell>
<TableCell>
<Badge variant="outline">
{t(`ui.common.role.${user.role}`, user.role)}
{t(`ui.admin.role.${user.role}`, user.role)}
</Badge>
</TableCell>
<TableCell>

View File

@@ -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<TenantAdmin[]>(
`/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<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
};
export async function fetchMe() {
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
return data;
}
// Relying Party Management
export type RelyingParty = {
clientId: string;

View File

@@ -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"

View File

@@ -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 = "테넌트 추가"

View File

@@ -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/);
});

View File

@@ -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
});
});

View File

@@ -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") {

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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()
}
}

View File

@@ -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")

View File

@@ -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()
}
}

View File

@@ -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)
})
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -5,7 +5,7 @@ class User implements Namespace {}
class Tenant implements Namespace {
related: {
owners: User[]
admins: (User | SubjectSet<Tenant, "owners">)[]
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)
}

View File

@@ -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"

View File

@@ -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 = "상세"

View File

@@ -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 = ""