1
0
forked from baron/baron-sso

feat: 테넌트/RP 관리자 할당 UI 및 ReBAC 권한 검증 도구 구현 #244

This commit is contained in:
2026-02-11 13:26:26 +09:00
parent 8856485265
commit 68df43f3a8
24 changed files with 1547 additions and 48 deletions

View File

@@ -7,16 +7,22 @@ import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import LoginPage from "../features/auth/LoginPage";
import RPDetailPage from "../features/relying-parties/routes/RPDetailPage";
import RPListPage from "../features/relying-parties/routes/RPListPage";
import RPOwnersTab from "../features/relying-parties/routes/RPOwnersTab";
import RPProfileTab from "../features/relying-parties/routes/RPProfileTab";
import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
import TenantGroupAdminsTab from "../features/tenant-groups/routes/TenantGroupAdminsTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
@@ -48,6 +54,7 @@ export const router = createBrowserRouter(
children: [
{ index: true, element: <TenantGroupProfileTab /> },
{ path: "tenants", element: <TenantGroupTenantsTab /> },
{ path: "admins", element: <TenantGroupAdminsTab /> },
],
},
{
@@ -55,11 +62,21 @@ export const router = createBrowserRouter(
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "relying-parties", element: <RPListPage /> },
{
path: "relying-parties/:id",
element: <RPDetailPage />,
children: [
{ index: true, element: <RPProfileTab /> },
{ path: "owners", element: <RPOwnersTab /> },
],
},
],
},
],

View File

@@ -5,33 +5,34 @@ import {
KeyRound,
LayoutDashboard,
LayoutGrid,
LogOut,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
LogOut,
Moon,
NotebookTabs,
Rocket,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { t } from "../../lib/i18n";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{
label: "ui.admin.nav.tenant_dashboard",
to: "/dashboard",
icon: ShieldHalf,
},
{ label: "ui.admin.nav.tenant_groups", to: "/tenant-groups", icon: LayoutGrid },
{ label: "ui.admin.nav.tenants", to: "/tenants", icon: Building2 },
{ label: "ui.admin.nav.relying_parties", to: "/relying-parties", icon: Rocket },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const navigate = useNavigate();
const [theme, setTheme] = useState<"light" | "dark">(() => {

View File

@@ -17,6 +17,7 @@ import {
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
const summaryCards = [
{
@@ -216,6 +217,8 @@ function GlobalOverviewPage() {
</CardContent>
</Card>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -0,0 +1,133 @@
import { useMutation } from "@tanstack/react-query";
import { ShieldAlert, CheckCircle2, XCircle, Search } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import apiClient from "../../../lib/apiClient";
type CheckPermissionResponse = {
allowed: boolean;
query: {
namespace: string;
object: string;
relation: string;
subject: string;
};
};
function PermissionChecker() {
const [namespace, setNamespace] = useState("Tenant");
const [object, setObject] = useState("");
const [relation, setRelation] = useState("manage");
const [subject, setSubject] = useState("");
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>("/v1/admin/debug/check-permission", {
params: { namespace, object, relation, subject },
});
return data;
},
});
const result = checkMutation.data;
return (
<Card className="bg-[var(--color-panel)] border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
ReBAC
</CardTitle>
<CardDescription>
(Subject) (Object) Ory Keto를 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>Namespace</Label>
<select
value={namespace}
onChange={e => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
</select>
</div>
<div className="space-y-2">
<Label>Relation</Label>
<Input
placeholder="view, manage, admins..."
value={relation}
onChange={e => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Object ID</Label>
<Input
placeholder="Tenant UUID 등"
value={object}
onChange={e => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Subject (User:ID)</Label>
<Input
placeholder="User:uuid 또는 Namespace:ID#Relation"
value={subject}
onChange={e => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
</div>
{checkMutation.isSuccess && (
<div className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed ? "bg-green-500/10 border-green-500/50 text-green-600" : "bg-destructive/10 border-destructive/50 text-destructive"
}`}>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
. ( )
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
.
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,77 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, Rocket } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchRelyingParty } from "../../../lib/adminApi";
function RPDetailPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const rpQuery = useQuery({
queryKey: ["relying-party", id],
queryFn: () => fetchRelyingParty(id!),
enabled: !!id,
});
const isOwnersTab = location.pathname.endsWith("/owners");
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/relying-parties" className="inline-flex items-center gap-2 hover:text-foreground">
<ArrowLeft size={14} />
Apps
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Rocket size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">
{rpQuery.data?.relyingParty?.name ?? "Loading App..."}
</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
Client ID: <span className="font-mono">{id}</span>
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b border-border">
<Link
to={`/relying-parties/${id}`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
!isOwnersTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
<Link
to={`/relying-parties/${id}/owners`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isOwnersTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
( )
</Link>
</div>
<div className="mt-6">
<Outlet context={{ rp: rpQuery.data, refetch: rpQuery.refetch }} />
</div>
</div>
);
}
export default RPDetailPage;

View File

@@ -0,0 +1,146 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Pencil, Plus, RefreshCw, Trash2, Rocket } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { deleteRelyingParty, fetchAllRelyingParties } from "../../../lib/adminApi";
function RPListPage() {
const navigate = useNavigate();
const query = useQuery({
queryKey: ["relying-parties"],
queryFn: () => fetchAllRelyingParties(),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRelyingParty(id),
onSuccess: () => {
query.refetch();
},
});
const items = query.data ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`애플리케이션 "${name}"을 삭제할까요?`)) {
return;
}
deleteMutation.mutate(id);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Apps</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold">(RP) </h2>
<p className="text-sm text-[var(--color-muted)]">
OAuth2 (Relying Party) .
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Rocket size={20} className="text-primary" />
Relying Party Registry
</CardTitle>
<CardDescription>
{items.length}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>TENANT</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={4}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={4}>
.
</TableCell>
</TableRow>
)}
{items.map((rp) => (
<TableRow key={rp.clientId}>
<TableCell className="font-semibold">{rp.name}</TableCell>
<TableCell className="text-xs font-mono">{rp.clientId}</TableCell>
<TableCell>
<Badge variant="outline">{rp.tenantId}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/relying-parties/${rp.clientId}`)}
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(rp.clientId, rp.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default RPListPage;

View File

@@ -0,0 +1,201 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { useState } from "react";
import { useOutletContext, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchRPOwners,
addRPOwner,
removeRPOwner,
fetchUsers
} from "../../../lib/adminApi";
function RPOwnersTab() {
const { id: clientId } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
if (!clientId) return null;
// 현재 소유자 목록
const ownersQuery = useQuery({
queryKey: ["rp-owners", clientId],
queryFn: () => fetchRPOwners(clientId),
enabled: !!clientId,
});
// 전체 사용자 목록 (소유자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1,
});
const addMutation = useMutation({
mutationFn: (subject: string) => addRPOwner(clientId, subject),
onSuccess: () => {
ownersQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (subject: string) => removeRPOwner(clientId, subject),
onSuccess: () => {
ownersQuery.refetch();
},
});
const handleAddOwner = (userId: string) => {
addMutation.mutate(`User:${userId}`);
};
const handleRemoveOwner = (subject: string, name?: string) => {
if (window.confirm(`${name || subject}의 소유 권한을 회수할까요?`)) {
removeMutation.mutate(subject);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 앱 소유자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>/</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{ownersQuery.data?.map((owner) => (
<TableRow key={owner.subject}>
<TableCell>
<div className="font-medium">{owner.name || owner.subject}</div>
<div className="text-[10px] text-muted-foreground">{owner.email}</div>
</TableCell>
<TableCell className="text-xs">{owner.type}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveOwner(owner.subject, owner.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items.filter(u => !ownersQuery.data?.some(o => o.subject === `User:${u.id}`)).map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddOwner(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default RPOwnersTab;

View File

@@ -0,0 +1,82 @@
import { useOutletContext } from "react-router-dom";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Badge } from "../../../components/ui/badge";
import type { RelyingParty, HydraClientReq } from "../../../lib/adminApi";
function RPProfileTab() {
const { rp } = useOutletContext<{
rp: { relyingParty: RelyingParty; oauth2Config: HydraClientReq }
}>();
if (!rp) return null;
return (
<div className="max-w-4xl space-y-6">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
OAuth2 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>App Name</Label>
<Input value={rp.relyingParty.name} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label>Client ID</Label>
<Input value={rp.relyingParty.clientId} disabled className="bg-muted font-mono text-xs" />
</div>
</div>
<div className="space-y-2">
<Label>Tenant ID</Label>
<div className="flex items-center gap-2">
<Input value={rp.relyingParty.tenantId} disabled className="bg-muted flex-1" />
<Badge>Owner Tenant</Badge>
</div>
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<div className="flex flex-wrap gap-2 p-3 border rounded-md bg-muted/30">
{rp.oauth2Config.scope?.split(" ").map(s => (
<Badge key={s} variant="outline">{s}</Badge>
))}
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)] border-primary/20">
<CardHeader>
<CardTitle className="text-sm font-semibold">OAuth2 Endpoints</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground">Authorization URL</Label>
<code className="block p-2 bg-black/20 rounded text-xs break-all">
https://sso.hmac.kr/oidc/oauth2/auth
</code>
</div>
<div className="space-y-1">
<Label className="text-[10px] uppercase text-muted-foreground">Token URL</Label>
<code className="block p-2 bg-black/20 rounded text-xs break-all">
https://sso.hmac.kr/oidc/oauth2/token
</code>
</div>
</CardContent>
</Card>
</div>
);
}
export default RPProfileTab;

View File

@@ -0,0 +1,199 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { useState } from "react";
import { useOutletContext } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchGroupAdmins,
addGroupAdmin,
removeGroupAdmin,
fetchUsers,
type TenantGroupSummary
} from "../../../lib/adminApi";
function TenantGroupAdminsTab() {
const { group } = useOutletContext<{
group: TenantGroupSummary;
}>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-group-admins", group.id],
queryFn: () => fetchGroupAdmins(group.id),
enabled: !!group.id,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1, // 2글자 이상 입력 시 검색
});
const addMutation = useMutation({
mutationFn: (userId: string) => addGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupAdmin(group.id, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 그룹 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupAdminsTab;

View File

@@ -15,6 +15,7 @@ function TenantGroupDetailPage() {
});
const isTenantsTab = location.pathname.endsWith("/tenants");
const isAdminTab = location.pathname.endsWith("/admins");
return (
<div className="space-y-8">
@@ -48,7 +49,7 @@ function TenantGroupDetailPage() {
<Link
to={`/tenant-groups/${id}`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
!isTenantsTab
!isTenantsTab && !isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
@@ -65,6 +66,16 @@ function TenantGroupDetailPage() {
>
({groupQuery.data?.tenants?.length ?? 0})
</Link>
<Link
to={`/tenant-groups/${id}/admins`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isAdminTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
</div>
<div className="mt-6">

View File

@@ -0,0 +1,198 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2, ShieldCheck, Search, UserPlus } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import {
fetchTenantAdmins,
addTenantAdmin,
removeTenantAdmin,
fetchUsers
} from "../../../lib/adminApi";
function TenantAdminsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
if (!tenantId) return null;
// 현재 관리자 목록
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
// 전체 사용자 목록 (관리자 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }],
queryFn: () => fetchUsers(100, 0, searchTerm),
enabled: searchTerm.length > 1,
});
const addMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
setSearchTerm("");
},
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => {
adminsQuery.refetch();
},
});
const handleAddAdmin = (userId: string) => {
addMutation.mutate(userId);
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) {
removeMutation.mutate(userId);
}
};
return (
<div className="grid gap-6 lg:grid-cols-2 mt-6">
{/* 현재 테넌트 관리자 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">{admin.name || "Unknown"}</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle>
</div>
<CardDescription>
( ).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{searchTerm.length < 2 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{searchTerm.length >= 2 && usersQuery.data?.items.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{usersQuery.data?.items.filter(u => !adminsQuery.data?.some(a => a.id === u.id)).map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">{user.email}</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantAdminsTab;

View File

@@ -16,6 +16,7 @@ function TenantDetailPage() {
});
const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins");
return (
<div className="space-y-8">
@@ -44,7 +45,7 @@ function TenantDetailPage() {
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
!isFederationTab && !isAdminTab && !location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
@@ -61,6 +62,16 @@ function TenantDetailPage() {
>
Federation
</Link>
<Link
to={`/tenants/${tenantId}/admins`}
className={`px-4 py-2 text-sm font-medium ${
isAdminTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Admins
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${

View File

@@ -287,6 +287,50 @@ export async function removeTenantFromGroup(groupId: string, tenantId: string) {
);
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export type GroupAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchGroupAdmins(groupId: string) {
const { data } = await apiClient.get<GroupAdmin[]>(
`/v1/admin/tenant-groups/${groupId}/admins`,
);
return data;
}
export async function addGroupAdmin(groupId: string, userId: string) {
await apiClient.post(`/v1/admin/tenant-groups/${groupId}/admins/${userId}`);
}
export async function removeGroupAdmin(groupId: string, userId: string) {
await apiClient.delete(
`/v1/admin/tenant-groups/${groupId}/admins/${userId}`,
);
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
@@ -465,5 +509,55 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
}
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
}
export type RPOwner = {
subject: string;
name?: string;
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(`/v1/admin/relying-parties/${clientId}/owners/${subject}`);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}

View File

@@ -1308,3 +1308,4 @@ verify = "Verify"
action = "Action"
msg.admin.logout_confirm = "Are you sure you want to log out?"
ui.admin.nav.logout = "Logout"
ui.admin.nav.relying_parties = "Apps (RP)"

View File

@@ -1308,3 +1308,4 @@ verify = "본인인증"
action = "로그인하기"
msg.admin.logout_confirm = "로그아웃 하시겠습니까?"
ui.admin.nav.logout = "로그아웃"
ui.admin.nav.relying_parties = "애플리케이션(RP)"

View File

@@ -256,15 +256,16 @@ func main() {
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService)
tenantGroupHandler := handler.NewTenantGroupHandler(tenantGroupService, kratosAdminService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
@@ -559,6 +560,7 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
admin.Get("/debug/check-permission", requireSuperAdmin, adminHandler.CheckPermission)
// Tenant Management (Super Admin Only)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
@@ -567,6 +569,9 @@ func main() {
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
// Tenant Group Management (Super Admin Only)
admin.Get("/tenant-groups", requireSuperAdmin, tenantGroupHandler.ListGroups)
@@ -576,9 +581,15 @@ func main() {
admin.Delete("/tenant-groups/:id", requireSuperAdmin, tenantGroupHandler.DeleteGroup)
admin.Post("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.AddTenantToGroup)
admin.Delete("/tenant-groups/:id/tenants/:tenantId", requireSuperAdmin, tenantGroupHandler.RemoveTenantFromGroup)
admin.Get("/tenant-groups/:id/admins", requireSuperAdmin, tenantGroupHandler.ListAdmins)
admin.Post("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.AddAdmin)
admin.Delete("/tenant-groups/:id/admins/:userId", requireSuperAdmin, tenantGroupHandler.RemoveAdmin)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
admin.Get("/relying-parties/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.ListOwners)
admin.Post("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.AddOwner)
admin.Delete("/relying-parties/:id/owners/:subject", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"), relyingPartyHandler.RemoveOwner)
// Relying Party Management (Tenant Context)
admin.Post("/tenants/:tenantId/relying-parties",
@@ -703,4 +714,4 @@ func main() {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}
}

View File

@@ -1,22 +1,51 @@
package handler
import (
"baron-sso-backend/internal/service"
"runtime"
"time"
"github.com/gofiber/fiber/v2"
)
type AdminHandler struct{}
type AdminHandler struct {
Keto service.KetoService
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
func NewAdminHandler(keto service.KetoService) *AdminHandler {
return &AdminHandler{Keto: keto}
}
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
}
func (h *AdminHandler) CheckPermission(c *fiber.Ctx) error {
namespace := c.Query("namespace")
object := c.Query("object")
relation := c.Query("relation")
subject := c.Query("subject")
if namespace == "" || object == "" || relation == "" || subject == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "namespace, object, relation, and subject are required"})
}
allowed, err := h.Keto.CheckPermission(c.Context(), subject, namespace, object, relation)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{
"allowed": allowed,
"query": fiber.Map{
"namespace": namespace,
"object": object,
"relation": relation,
"subject": subject,
},
})
}
// GetSystemStats returns runtime statistics for monitoring
func (h *AdminHandler) GetSystemStats(c *fiber.Ctx) error {
var m runtime.MemStats

View File

@@ -166,6 +166,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
summary := h.mapClientSummary(*client)
return c.JSON(clientDetailResponse{
Client: summary,
@@ -239,6 +244,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "X-Tenant-ID header is required"})
}
// Set for audit logging
c.Locals("tenant_id", targetTenantID)
// Validate Permission
isAllowed := false
if profile.Role == domain.RoleSuperAdmin {
@@ -371,6 +379,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
clientType := ""
if req.Type != nil {
clientType = strings.ToLower(strings.TrimSpace(*req.Type))
@@ -446,6 +459,14 @@ func (h *DevHandler) DeleteClient(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "client id is required"})
}
// Fetch first for audit log tenant_id
client, err := h.Hydra.GetClient(c.Context(), clientID)
if err == nil {
if tid, ok := client.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
}
if err := h.Hydra.DeleteClient(c.Context(), clientID); err != nil {
if errors.Is(err, service.ErrHydraNotFound) {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "client not found"})
@@ -625,6 +646,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
// Set for audit logging
if tid, ok := current.Metadata["tenant_id"].(string); ok {
c.Locals("tenant_id", tid)
}
// 3. Update Hydra
current.ClientSecret = newSecret
updated, err := h.Hydra.UpdateClient(c.Context(), clientID, *current)

View File

@@ -10,10 +10,11 @@ import (
type RelyingPartyHandler struct {
Service service.RelyingPartyService
UserSvc *service.KratosAdminService
}
func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s}
func NewRelyingPartyHandler(s service.RelyingPartyService, userSvc *service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s, UserSvc: userSvc}
}
func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error {
@@ -110,3 +111,58 @@ func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *RelyingPartyHandler) ListOwners(c *fiber.Ctx) error {
clientID := c.Params("id")
subjects, err := h.Service.ListOwners(c.Context(), clientID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type ownerInfo struct {
Subject string `json:"subject"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Type string `json:"type"` // "user" or "group"
}
owners := make([]ownerInfo, 0, len(subjects))
for _, s := range subjects {
info := ownerInfo{Subject: s, Type: "unknown"}
if len(s) > 5 && s[:5] == "User:" {
info.Type = "user"
userID := s[5:]
identity, err := h.UserSvc.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
info.Name, _ = identity.Traits["name"].(string)
info.Email, _ = identity.Traits["email"].(string)
}
} else if len(s) > 10 && s[:10] == "UserGroup:" {
info.Type = "group"
// Group name enrichment could be added if we have a GroupService here
}
owners = append(owners, info)
}
return c.JSON(owners)
}
func (h *RelyingPartyHandler) AddOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject") // e.g. "User:uuid"
if err := h.Service.AddOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner added"})
}
func (h *RelyingPartyHandler) RemoveOwner(c *fiber.Ctx) error {
clientID := c.Params("id")
subject := c.Params("subject")
if err := h.Service.RemoveOwner(c.Context(), clientID, subject); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "owner removed"})
}

View File

@@ -9,11 +9,12 @@ import (
)
type TenantGroupHandler struct {
Service service.TenantGroupService
Service service.TenantGroupService
UserService *service.KratosAdminService
}
func NewTenantGroupHandler(svc service.TenantGroupService) *TenantGroupHandler {
return &TenantGroupHandler{Service: svc}
func NewTenantGroupHandler(svc service.TenantGroupService, userSvc *service.KratosAdminService) *TenantGroupHandler {
return &TenantGroupHandler{Service: svc, UserService: userSvc}
}
type tenantGroupSummary struct {
@@ -120,6 +121,59 @@ func (h *TenantGroupHandler) RemoveTenantFromGroup(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "tenant removed from group"})
}
func (h *TenantGroupHandler) ListAdmins(c *fiber.Ctx) error {
groupID := c.Params("id")
userIDs, err := h.Service.ListGroupAdmins(c.Context(), groupID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := make([]adminInfo, 0, len(userIDs))
for _, uid := range userIDs {
identity, err := h.UserService.GetIdentity(c.Context(), uid)
if err == nil && identity != nil {
name, _ := identity.Traits["name"].(string)
email, _ := identity.Traits["email"].(string)
admins = append(admins, adminInfo{
ID: uid,
Name: name,
Email: email,
})
} else {
// Fallback if identity not found in Kratos
admins = append(admins, adminInfo{ID: uid})
}
}
return c.JSON(admins)
}
func (h *TenantGroupHandler) AddAdmin(c *fiber.Ctx) error {
groupID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.AddGroupAdmin(c.Context(), groupID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin added to group"})
}
func (h *TenantGroupHandler) RemoveAdmin(c *fiber.Ctx) error {
groupID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.RemoveGroupAdmin(c.Context(), groupID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin removed from group"})
}
func mapTenantGroupSummary(g domain.TenantGroup) tenantGroupSummary {
tenants := make([]tenantSummary, 0, len(g.Tenants))
for _, t := range g.Tenants {

View File

@@ -15,10 +15,11 @@ type TenantHandler struct {
DB *gorm.DB
Service service.TenantService
Keto service.KetoService
UserSvc *service.KratosAdminService
}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc, Keto: keto}
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, userSvc *service.KratosAdminService) *TenantHandler {
return &TenantHandler{DB: db, Service: svc, Keto: keto, UserSvc: userSvc}
}
type tenantSummary struct {
@@ -327,6 +328,58 @@ func (h *TenantHandler) DeleteTenant(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
tenantID := c.Params("id")
userIDs, err := h.Service.ListTenantAdmins(c.Context(), tenantID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
type adminInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
admins := make([]adminInfo, 0, len(userIDs))
for _, uid := range userIDs {
identity, err := h.UserSvc.GetIdentity(c.Context(), uid)
if err == nil && identity != nil {
name, _ := identity.Traits["name"].(string)
email, _ := identity.Traits["email"].(string)
admins = append(admins, adminInfo{
ID: uid,
Name: name,
Email: email,
})
} else {
admins = append(admins, adminInfo{ID: uid})
}
}
return c.JSON(admins)
}
func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.AddTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin added to tenant"})
}
func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if err := h.Service.RemoveTenantAdmin(c.Context(), tenantID, userID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "admin removed from tenant"})
}
func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains {

View File

@@ -16,6 +16,9 @@ type RelyingPartyService interface {
Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error)
Delete(ctx context.Context, clientID string) error
CheckPermission(ctx context.Context, userID, clientID, relation string) (bool, error)
AddOwner(ctx context.Context, clientID, subject string) error
RemoveOwner(ctx context.Context, clientID, subject string) error
ListOwners(ctx context.Context, clientID string) ([]string, error)
}
type relyingPartyService struct {
@@ -163,6 +166,27 @@ func (s *relyingPartyService) CheckPermission(ctx context.Context, userID, clien
return s.ketoService.CheckPermission(ctx, userID, "RelyingParty", clientID, relation)
}
func (s *relyingPartyService) AddOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.CreateRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) RemoveOwner(ctx context.Context, clientID, subject string) error {
return s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "owners", subject)
}
func (s *relyingPartyService) ListOwners(ctx context.Context, clientID string) ([]string, error) {
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", clientID, "owners", "")
if err != nil {
return nil, err
}
subjects := make([]string, 0, len(tuples))
for _, t := range tuples {
subjects = append(subjects, t.SubjectID)
}
return subjects, nil
}
func (s *relyingPartyService) mapHydraToDomain(client *domain.HydraClient) *domain.RelyingParty {
if client == nil {
return nil

View File

@@ -15,6 +15,9 @@ type TenantGroupService interface {
DeleteGroup(ctx context.Context, id string) error
AddTenantToGroup(ctx context.Context, groupID, tenantID string) error
RemoveTenantFromGroup(ctx context.Context, groupID, tenantID string) error
AddGroupAdmin(ctx context.Context, groupID, userID string) error
RemoveGroupAdmin(ctx context.Context, groupID, userID string) error
ListGroupAdmins(ctx context.Context, groupID string) ([]string, error)
}
type tenantGroupService struct {
@@ -92,3 +95,36 @@ func (s *tenantGroupService) RemoveTenantFromGroup(ctx context.Context, groupID,
}
return nil
}
func (s *tenantGroupService) AddGroupAdmin(ctx context.Context, groupID, userID string) error {
if s.keto == nil {
return nil
}
return s.keto.CreateRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
}
func (s *tenantGroupService) RemoveGroupAdmin(ctx context.Context, groupID, userID string) error {
if s.keto == nil {
return nil
}
return s.keto.DeleteRelation(ctx, "TenantGroup", groupID, "admins", "User:"+userID)
}
func (s *tenantGroupService) ListGroupAdmins(ctx context.Context, groupID string) ([]string, error) {
if s.keto == nil {
return []string{}, nil
}
tuples, err := s.keto.ListRelations(ctx, "TenantGroup", groupID, "admins", "")
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
// subject_id is "User:uuid"
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
}
}
return userIDs, nil
}

View File

@@ -21,6 +21,9 @@ type TenantService interface {
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가
AddTenantAdmin(ctx context.Context, tenantID, userID string) error
RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error
ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error)
}
type tenantService struct {
@@ -208,3 +211,35 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug)
}
func (s *tenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.CreateRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) RemoveTenantAdmin(ctx context.Context, tenantID, userID string) error {
if s.keto == nil {
return errors.New("keto service not initialized")
}
return s.keto.DeleteRelation(ctx, "Tenant", tenantID, "admins", "User:"+userID)
}
func (s *tenantService) ListTenantAdmins(ctx context.Context, tenantID string) ([]string, error) {
if s.keto == nil {
return nil, errors.New("keto service not initialized")
}
tuples, err := s.keto.ListRelations(ctx, "Tenant", tenantID, "admins", "")
if err != nil {
return nil, err
}
userIDs := make([]string, 0, len(tuples))
for _, t := range tuples {
if len(t.SubjectID) > 5 && t.SubjectID[:5] == "User:" {
userIDs = append(userIDs, t.SubjectID[5:])
}
}
return userIDs, nil
}