1
0
forked from baron/baron-sso

테넌트 소유자, 관리자 분리

This commit is contained in:
2026-03-03 12:38:27 +09:00
parent 7bb1f3f702
commit 86ef9c6f60
23 changed files with 1091 additions and 516 deletions

View File

@@ -8,7 +8,7 @@ import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage"; import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage"; import TenantListPage from "../features/tenants/routes/TenantListPage";
@@ -48,7 +48,7 @@ export const router = createBrowserRouter(
element: <TenantDetailPage />, element: <TenantDetailPage />,
children: [ children: [
{ index: true, element: <TenantProfilePage /> }, { index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> }, { path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> }, { path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> }, { path: "schema", element: <TenantSchemaPage /> },
], ],

View File

@@ -0,0 +1,549 @@
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 isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins"); const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization"); const isOrganizationTab = location.pathname.includes("/organization");
return ( return (
@@ -59,7 +59,7 @@ function TenantDetailPage() {
to={`/tenants/${tenantId}`} to={`/tenants/${tenantId}`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isFederationTab && !isFederationTab &&
!isAdminTab && !isPermissionsTab &&
!location.pathname.includes("/schema") && !location.pathname.includes("/schema") &&
!isOrganizationTab !isOrganizationTab
? "text-primary border-b-2 border-primary" ? "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_federation", "외부 연동")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/admins`} to={`/tenants/${tenantId}/permissions`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isAdminTab isPermissionsTab
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")} {t("ui.admin.tenants.detail.tab_permissions", "권한")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/organization`} to={`/tenants/${tenantId}/organization`}

View File

@@ -438,10 +438,16 @@ function UserCreatePage() {
{...register("role")} {...register("role")}
> >
<option value="user"> <option value="user">
{t("ui.common.role.user", "User")} {t("ui.admin.role.user", "TENANT MEMBER")}
</option> </option>
<option value="admin"> <option value="tenant_admin">
{t("ui.common.role.admin", "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> </option>
</select> </select>
</div> </div>

View File

@@ -287,10 +287,16 @@ function UserDetailPage() {
{...register("role")} {...register("role")}
> >
<option value="user"> <option value="user">
{t("ui.common.role.user", "User")} {t("ui.admin.role.user", "TENANT MEMBER")}
</option> </option>
<option value="admin"> <option value="tenant_admin">
{t("ui.common.role.admin", "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> </option>
</select> </select>
</div> </div>

View File

@@ -245,7 +245,7 @@ function UserListPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> <Badge variant="outline">
{t(`ui.common.role.${user.role}`, user.role)} {t(`ui.admin.role.${user.role}`, user.role)}
</Badge> </Badge>
</TableCell> </TableCell>
<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}`); 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 // Group Management
export type GroupMember = { export type GroupMember = {
id: string; id: string;

View File

@@ -703,7 +703,7 @@ view_audit_logs = "View Audit Logs"
rp_admin = "RP ADMIN" rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN" super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN" tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER" user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "Tenant Add" add = "Tenant Add"

View File

@@ -772,10 +772,10 @@ policy_gate = "정책 게이트"
total_tenants = "전체 테넌트" total_tenants = "전체 테넌트"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "SUPER ADMIN" super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "TENANT ADMIN" tenant_admin = "테넌트 관리자 (Tenant Admin)"
tenant_member = "TENANT MEMBER" user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"

View File

@@ -0,0 +1,101 @@
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 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" },
],
});
});
await page.goto("/tenants/tenant-1/owners");
// Check if the page title and the owner are visible
await expect(page.locator("h3")).toContainText("테넌트 소유자");
await expect(page.locator("table")).toContainText("Owner One");
await expect(page.locator("table")).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 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/owners");
// 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

@@ -566,6 +566,9 @@ func main() {
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins) 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.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin) admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
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) // Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization", requireAdmin) 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) 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 { if err != nil {
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
return err return err

View File

@@ -3925,104 +3925,83 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
} }
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, 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")) appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
mockRole := c.Get("X-Test-Role") mockRole := c.Get("X-Test-Role")
if mockRole == "" { if mockRole == "" {
mockRole = c.Get("X-Mock-Role") mockRole = c.Get("X-Mock-Role")
} }
// Always log in development to see what's happening token := h.getBearerToken(c)
if appEnv == "dev" || appEnv == "development" || appEnv == "" { cookie := c.Get("Cookie")
slog.Info("🔍 [AUTH_DEBUG] Checking mock role",
"env", appEnv, var profile *domain.UserProfileResponse
"mockRole", mockRole, var err error
"X-Test-Role", c.Get("X-Test-Role"), cacheKey := ""
"X-Mock-Role", c.Get("X-Mock-Role"),
) // 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 {
// Fall through to role override check
}
}
}
if profile == nil {
// Fetch from Kratos (SoT)
if token != "" {
profile, err = h.getKratosProfile(token)
if err != nil && h.Hydra != nil {
// Fallback to Hydra introspection
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 // 2. Role Override for real profile or fallback to Mock Profile
if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" { if profile != nil {
slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole) if isDev && mockRole != "" {
mockProfile := &domain.UserProfileResponse{ slog.Info("🔑 [AUTH_DEBUG] Overriding real profile role with mock role",
"email", profile.Email, "oldRole", profile.Role, "newRole", mockRole)
profile.Role = mockRole
}
} else if isDev && mockRole != "" {
slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole)
profile = &domain.UserProfileResponse{
ID: "00000000-0000-0000-0000-000000000000", ID: "00000000-0000-0000-0000-000000000000",
Email: "mock@hmac.kr", Email: "mock@hmac.kr",
Name: "Dev Mock User", Name: "Dev Mock User",
Role: mockRole, Role: mockRole,
} }
if tid := c.Get("X-Tenant-ID"); tid != "" { if tid := c.Get("X-Tenant-ID"); tid != "" {
mockProfile.TenantID = &tid profile.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
}
}
} }
} }
// 2. Fetch from Kratos (SoT) if profile == nil {
if token != "" {
profile, err = h.getKratosProfile(token)
} else {
if cookie != "" {
profile, err = h.getKratosProfileWithCookie(cookie)
}
}
if err != nil || profile == nil {
return nil, errors.New("invalid session (trace:resolve_profile)") return nil, errors.New("invalid session (trace:resolve_profile)")
} }
// 3. Post-Process (Defaults & Metadata Enrichment) // 3. Post-Process (Defaults & Metadata Enrichment)
// Default Role if missing (migration safety)
if profile.Role == "" { if profile.Role == "" {
profile.Role = domain.RoleUser profile.Role = domain.RoleUser
} }
// Fetch Tenant Metadata if missing // Fetch Tenant Metadata if missing
// Case A: Have TenantID from Kratos -> Fetch by ID
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
profile.Tenant = tenant profile.Tenant = tenant
} }
} }
// Case B: Have CompanyCode but no TenantID -> Fetch by Slug
if profile.Tenant == nil && profile.CompanyCode != "" { if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant profile.Tenant = tenant
@@ -4033,7 +4012,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
} }
// 4. Save to Redis Cache (Short TTL) // 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" { if h.RedisService != nil && cacheKey != "" && err == nil {
if data, err := json.Marshal(profile); err == nil { if data, err := json.Marshal(profile); err == nil {
ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttlStr := os.Getenv("PROFILE_CACHE_TTL")
ttl := 30 * time.Minute // Default TTL ttl := 30 * time.Minute // Default TTL
@@ -5060,12 +5039,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]
return nil return nil
} }
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken) intro, err := h.Hydra.IntrospectToken(ctx, token)
if err != nil { if err != nil {
slog.Error("Hydra introspection failed", "error", err)
return nil, 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) email, _ := traits["email"].(string)
name, _ := traits["name"].(string) name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string) phone, _ := traits["phone_number"].(string)
@@ -5101,8 +5104,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
profile.Metadata[k] = v 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) { func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
@@ -5110,44 +5120,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
if err != nil { if err != nil {
return nil, err return nil, err
} }
return h.mapKratosIdentityToProfile(identityID, traits), nil
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
} }
// UpdateMe - Updates current user's profile with phone verification check // UpdateMe - Updates current user's profile with phone verification check

View File

@@ -140,7 +140,7 @@ type AsyncMockTenantService struct {
mock.Mock 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 return nil, nil
} }

View File

@@ -221,7 +221,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid 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 err != nil {
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, err.Error()) return errorJSON(c, fiber.StatusConflict, err.Error())
@@ -423,20 +429,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
} }
userID := strings.TrimPrefix(rel.SubjectID, "User:") userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details from Kratos // Fetch user details - Try Kratos first, then local DB
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) name := "Unknown"
if err != nil { email := "Unknown"
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
continue
}
name := "" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if n, ok := identity.Traits["name"].(string); ok { if err == nil && identity != nil {
name = n if n, ok := identity.Traits["name"].(string); ok {
} name = n
email := "" }
if e, ok := identity.Traits["email"].(string); ok { if e, ok := identity.Traits["email"].(string); ok {
email = e 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{ admins = append(admins, adminInfo{
@@ -464,6 +478,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate, 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) return c.SendStatus(fiber.StatusOK)
@@ -489,6 +511,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) 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 { func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains)) domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains { for _, d := range t.Domains {

View File

@@ -21,8 +21,8 @@ type MockTenantService struct {
mock.Mock mock.Mock
} }
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { 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) args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
} }
@@ -133,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
} }
body, _ := json.Marshal(input) 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) Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))

View File

@@ -59,8 +59,8 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
c.Locals("tenant_id", objectID) c.Locals("tenant_id", objectID)
} }
// Check with Keto // Check with Keto - add User: prefix to subject
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation)
if err != nil { if err != nil {
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
return errorJSON(c, fiber.StatusInternalServerError, "permission check error") return errorJSON(c, fiber.StatusInternalServerError, "permission check error")

View File

@@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil 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/admin/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,7 +13,7 @@ import (
) )
type TenantService interface { 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) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
@@ -90,7 +90,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return s.repo.FindByIDs(ctx, allIDs) 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 // Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok { if ok, msg := utils.ValidateSlug(slug); !ok {
return nil, errors.New(msg) return nil, errors.New(msg)
@@ -119,15 +119,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
return nil, err return nil, err
} }
// [Keto] Sync hierarchy via Outbox if ParentID exists // [Keto] Sync hierarchy and ownership via Outbox
if s.outboxRepo != nil && tenant.ParentID != nil { if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ // Sync hierarchy
Namespace: "Tenant", if tenant.ParentID != nil {
Object: tenant.ID, if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Relation: "parents", Namespace: "Tenant",
Subject: "Tenant:" + *tenant.ParentID, Object: tenant.ID,
Action: domain.KetoOutboxActionCreate, 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) // 3. Add Domains (Auto-verify for manual admin registration)
@@ -187,12 +221,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
// [Keto] Sync relation via Outbox // [Keto] Sync relation via Outbox
if s.outboxRepo != nil { if s.outboxRepo != nil {
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { 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 // Check if user already exists in our Read-Model
if s.userRepo != nil { if s.userRepo != nil {
user, err := s.userRepo.FindByEmail(ctx, adminEmail) user, err := s.userRepo.FindByEmail(ctx, adminEmail)
if err == nil && user != nil { 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{ _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: tenant.ID, Object: tenant.ID,
@@ -200,6 +242,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
Subject: "User:" + user.ID, Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
} else { } else {
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail) slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
} }

View File

@@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
// Mock: slug already exists // Mock: slug already exists
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) 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.Error(t, err)
assert.Contains(t, err.Error(), "already exists") assert.Contains(t, err.Error(), "already exists")
assert.Nil(t, tenant) assert.Nil(t, tenant)
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Case 1: Too short // 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) assert.Error(t, err)
// Case 2: Invalid characters // 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) 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("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() 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.NoError(t, err)
assert.NotNil(t, tenant) assert.NotNil(t, tenant)
assert.Equal(t, "t1", tenant.ID) assert.Equal(t, "t1", tenant.ID)
mockRepo.AssertExpectations(t) 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) { func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared) mockOutbox := new(MockKetoOutboxRepositoryShared)
@@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockRepo.On("Update", ctx, mock.Anything).Return(nil) mockRepo.On("Update", ctx, mock.Anything).Return(nil)
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil) mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
// Now using Outbox instead of direct Keto call // 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 { 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 e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
})).Return(nil) })).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) err := svc.ApproveTenant(ctx, tenantID)
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -5,7 +5,7 @@ class User implements Namespace {}
class Tenant implements Namespace { class Tenant implements Namespace {
related: { related: {
owners: User[] owners: User[]
admins: (User | SubjectSet<Tenant, "owners">)[] admins: User[]
members: User[] members: User[]
parents: Tenant[] parents: Tenant[]
} }
@@ -14,12 +14,18 @@ class Tenant implements Namespace {
view: (ctx: Context): boolean => view: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) || this.related.members.includes(ctx.subject) ||
this.related.admins.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.view(ctx)), this.related.parents.traverse((p) => p.permits.view(ctx)),
manage: (ctx: Context): boolean => manage: (ctx: Context): boolean =>
this.related.admins.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage(ctx)), 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 => create_subtenant: (ctx: Context): boolean =>
this.permits.manage(ctx) this.permits.manage(ctx)
} }