From 7e09764ad90841eda8e3596480ecb4586507928c Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 4 Feb 2026 15:01:13 +0900 Subject: [PATCH] =?UTF-8?q?ReBAC=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/src/app/routes.tsx | 2 + .../src/components/layout/RoleSwitcher.tsx | 6 +- .../features/api-keys/ApiKeyCreatePage.tsx | 2 - .../RelyingPartyCreatePage.tsx | 228 +++++++++++++++++ .../relying-parties/RelyingPartyListPage.tsx | 175 +++++++++++++ .../tenants/routes/TenantProfilePage.tsx | 2 +- .../routes/TenantRelyingPartyCreatePage.tsx | 239 ++++++++++++++++++ .../routes/TenantRelyingPartyDetailPage.tsx | 126 +++++++++ .../routes/TenantRelyingPartyListPage.tsx | 155 ++++++++++++ backend/internal/bootstrap/bootstrap.go | 2 +- backend/internal/bootstrap/keto_sync.go | 52 ++++ backend/internal/domain/hydra_models.go | 38 +++ backend/internal/domain/relying_party.go | 26 ++ backend/internal/handler/auth_handler.go | 49 +++- .../internal/handler/relying_party_handler.go | 111 ++++++++ backend/internal/handler/user_handler.go | 121 +++++---- backend/internal/middleware/api_key_auth.go | 2 +- backend/internal/middleware/rbac.go | 20 +- .../repository/relying_party_repository.go | 61 +++++ .../internal/repository/user_repository.go | 22 ++ .../internal/service/relying_party_service.go | 155 ++++++++++++ 21 files changed, 1532 insertions(+), 62 deletions(-) create mode 100644 adminfront/src/features/relying-parties/RelyingPartyCreatePage.tsx create mode 100644 adminfront/src/features/relying-parties/RelyingPartyListPage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantRelyingPartyCreatePage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantRelyingPartyDetailPage.tsx create mode 100644 adminfront/src/features/tenants/routes/TenantRelyingPartyListPage.tsx create mode 100644 backend/internal/bootstrap/keto_sync.go create mode 100644 backend/internal/domain/hydra_models.go create mode 100644 backend/internal/domain/relying_party.go create mode 100644 backend/internal/handler/relying_party_handler.go create mode 100644 backend/internal/repository/relying_party_repository.go create mode 100644 backend/internal/service/relying_party_service.go diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 3f43e7eb..34c112d0 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -15,6 +15,7 @@ import TenantRelyingPartyListPage from "../features/tenants/routes/TenantRelying import TenantRelyingPartyCreatePage from "../features/tenants/routes/TenantRelyingPartyCreatePage"; import TenantRelyingPartyDetailPage from "../features/tenants/routes/TenantRelyingPartyDetailPage"; import RelyingPartyListPage from "../features/relying-parties/RelyingPartyListPage"; +import RelyingPartyCreatePage from "../features/relying-parties/RelyingPartyCreatePage"; import UserCreatePage from "../features/users/UserCreatePage"; import UserDetailPage from "../features/users/UserDetailPage"; import UserListPage from "../features/users/UserListPage"; @@ -33,6 +34,7 @@ export const router = createBrowserRouter( { path: "users/new", element: }, { path: "users/:id", element: }, { path: "relying-parties", element: }, + { path: "relying-parties/new", element: }, { path: "tenants", element: }, { path: "tenants/new", element: }, { diff --git a/adminfront/src/components/layout/RoleSwitcher.tsx b/adminfront/src/components/layout/RoleSwitcher.tsx index c2036d7f..3245ad01 100644 --- a/adminfront/src/components/layout/RoleSwitcher.tsx +++ b/adminfront/src/components/layout/RoleSwitcher.tsx @@ -22,7 +22,7 @@ const RoleSwitcher: React.FC = () => { window.location.reload(); }; - if (process.env.NODE_ENV === 'production') return null; + if (import.meta.env.MODE === 'production') return null; return (
{
πŸ›  DEV Role Switcher
- {(['super_admin', 'tenant_admin', 'user'] as const).map(role => ( + {(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => ( ))}
diff --git a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx index 09280945..4ad2fe0e 100644 --- a/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx @@ -4,12 +4,10 @@ import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } fro import * as React from "react"; import { useForm } from "react-hook-form"; 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"; diff --git a/adminfront/src/features/relying-parties/RelyingPartyCreatePage.tsx b/adminfront/src/features/relying-parties/RelyingPartyCreatePage.tsx new file mode 100644 index 00000000..ccdaf99d --- /dev/null +++ b/adminfront/src/features/relying-parties/RelyingPartyCreatePage.tsx @@ -0,0 +1,228 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Save } from "lucide-react"; +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { Button } from "../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { + createRelyingParty, + fetchTenants, +} from "../../lib/adminApi"; +import type { HydraClientReq } from "../../lib/adminApi"; +import { Badge } from "../../components/ui/badge"; + +function RelyingPartyCreatePage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [selectedTenantId, setSelectedTenantId] = useState(""); + const [formData, setFormData] = useState({ + client_name: "", + redirect_uris: [], + scope: "openid profile email", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }); + const [redirectUriInput, setRedirectUriInput] = useState(""); + + // ν…Œλ„ŒνŠΈ λͺ©λ‘ 쑰회 (μ„ νƒμš©) + const { data: tenantsData } = useQuery({ + queryKey: ["tenants", { limit: 100 }], + queryFn: () => fetchTenants(100, 0), + }); + const tenants = tenantsData?.items ?? []; + + const createMutation = useMutation({ + mutationFn: (data: HydraClientReq) => createRelyingParty(selectedTenantId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["relyingParties"] }); + navigate("/relying-parties"); + }, + }); + + const errorMsg = (createMutation.error as AxiosError<{ error?: string }>) + ?.response?.data?.error; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedTenantId) { + alert("μ†Œμ†λ  ν…Œλ„ŒνŠΈλ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”."); + return; + } + createMutation.mutate(formData); + }; + + const addRedirectUri = () => { + if (!redirectUriInput.trim()) return; + setFormData((prev) => ({ + ...prev, + redirect_uris: [...prev.redirect_uris, redirectUriInput.trim()], + })); + setRedirectUriInput(""); + }; + + const removeRedirectUri = (index: number) => { + setFormData((prev) => ({ + ...prev, + redirect_uris: prev.redirect_uris.filter((_, i) => i !== index), + })); + }; + + return ( +
+
+
+
+ + + Applications + + / + New App +
+

μƒˆ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 등둝

+

+ 전체 μ‹œμŠ€ν…œ μ°¨μ›μ—μ„œ μƒˆλ‘œμš΄ Relying Partyλ₯Ό λ“±λ‘ν•©λ‹ˆλ‹€. +

+
+
+ +
+ + + App & Tenant Assignment + + μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ΄ μ†Œμ†λ  ν…Œλ„ŒνŠΈμ™€ κΈ°λ³Έ 정보λ₯Ό μ„€μ •ν•©λ‹ˆλ‹€. + + + + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* ν…Œλ„ŒνŠΈ 선택 μΆ”κ°€ */} +
+ + +

이 앱을 관리할 쑰직을 μ„ νƒν•˜μ„Έμš”.

+
+ +
+ + + setFormData({ ...formData, client_name: e.target.value }) + } + placeholder="My Awesome App" + required + /> +
+ +
+ +
+ setRedirectUriInput(e.target.value)} + placeholder="https://myapp.com/callback" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addRedirectUri(); + } + }} + /> + +
+
+ {formData.redirect_uris.map((uri, idx) => ( + + {uri} + + + ))} +
+
+ +
+ + + setFormData({ ...formData, scope: e.target.value }) + } + placeholder="openid profile email" + /> +
+ +
+
+ + +
+
+ +
+ + + + +
+
+
+ ); +} + +export default RelyingPartyCreatePage; diff --git a/adminfront/src/features/relying-parties/RelyingPartyListPage.tsx b/adminfront/src/features/relying-parties/RelyingPartyListPage.tsx new file mode 100644 index 00000000..29ade93c --- /dev/null +++ b/adminfront/src/features/relying-parties/RelyingPartyListPage.tsx @@ -0,0 +1,175 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { Pencil, Plus, RefreshCw, Trash2, Share2, Building2 } from "lucide-react"; +import { useNavigate, Link } 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 { deleteRelyingParty, fetchAllRelyingParties } from "../../lib/adminApi"; + +function RelyingPartyListPage() { + const navigate = useNavigate(); + + const query = useQuery({ + queryKey: ["relyingParties", "all"], + queryFn: fetchAllRelyingParties, + }); + + const deleteMutation = useMutation({ + mutationFn: (clientId: string) => deleteRelyingParty(clientId), + onSuccess: () => { + query.refetch(); + }, + }); + + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + const fallbackError = + !errorMsg && query.isError ? "μ• ν”Œλ¦¬μΌ€μ΄μ…˜ λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." : null; + + const items = query.data ?? []; + + const handleDelete = (clientId: string, name: string) => { + if (!window.confirm(`μ•± "${name}"λ₯Ό μ‚­μ œν• κΉŒμš”?`)) { + return; + } + deleteMutation.mutate(clientId); + }; + + return ( +
+
+
+
+ Applications + / + List +
+

μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 관리

+

+ 전체 ν…Œλ„ŒνŠΈμ˜ Relying Party λͺ©λ‘μ„ ν™•μΈν•˜κ³  κ΄€λ¦¬ν•©λ‹ˆλ‹€. +

+
+
+ + +
+
+ + + +
+ Application Registry + + 총 {items.length}개 μ•± 등둝됨 + +
+
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + NAME + TENANT ID + CLIENT ID + UPDATED + ACTIONS + + + + {query.isLoading && ( + + λ‘œλ”© 쀑... + + )} + {!query.isLoading && items.length === 0 && ( + + + λ“±λ‘λœ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ΄ μ—†μŠ΅λ‹ˆλ‹€. + + + )} + {items.map((rp) => ( + + +
+ + {rp.name} +
+
+ +
+ + {rp.tenantId} +
+
+ + {rp.clientId} + + + {rp.updatedAt + ? new Date(rp.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
+ ))} +
+
+
+
+
+ ); +} + +export default RelyingPartyListPage; diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx index f623944e..f75a3f39 100644 --- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { Save, Trash2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Button } from "../../../components/ui/button"; import { diff --git a/adminfront/src/features/tenants/routes/TenantRelyingPartyCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantRelyingPartyCreatePage.tsx new file mode 100644 index 00000000..69ff5e9c --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantRelyingPartyCreatePage.tsx @@ -0,0 +1,239 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { ArrowLeft, Save } from "lucide-react"; +import { useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Input } from "../../../components/ui/input"; +import { Label } from "../../../components/ui/label"; +import { + createRelyingParty, +} from "../../../lib/adminApi"; +import type { HydraClientReq } from "../../../lib/adminApi"; +import { Badge } from "../../../components/ui/badge"; + +function TenantRelyingPartyCreatePage() { + const { tenantId } = useParams<{ tenantId: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const [formData, setFormData] = useState({ + client_name: "", + redirect_uris: [], + scope: "openid profile email", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_basic", + }); + const [redirectUriInput, setRedirectUriInput] = useState(""); + + const createMutation = useMutation({ + mutationFn: (data: HydraClientReq) => createRelyingParty(tenantId!, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["relyingParties", tenantId] }); + navigate(`/tenants/${tenantId}/relying-parties`); + }, + }); + + const errorMsg = (createMutation.error as AxiosError<{ error?: string }>) + ?.response?.data?.error; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createMutation.mutate(formData); + }; + + const addRedirectUri = () => { + if (!redirectUriInput.trim()) return; + setFormData((prev) => ({ + ...prev, + redirect_uris: [...prev.redirect_uris, redirectUriInput.trim()], + })); + setRedirectUriInput(""); + }; + + const removeRedirectUri = (index: number) => { + setFormData((prev) => ({ + ...prev, + redirect_uris: prev.redirect_uris.filter((_, i) => i !== index), + })); + }; + + return ( +
+
+
+
+ + + Back to List + + / + New App +
+

μƒˆ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 등둝

+

+ Ory Hydra OAuth2 Clientλ₯Ό μƒμ„±ν•˜κ³  ν˜„μž¬ ν…Œλ„ŒνŠΈμ— μ—°κ²°ν•©λ‹ˆλ‹€. +

+
+
+ +
+ + + Basic Information + + μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ˜ κΈ°λ³Έ 정보λ₯Ό μž…λ ₯ν•˜μ„Έμš”. + + + + {errorMsg && ( +
+ {errorMsg} +
+ )} + +
+ + + setFormData({ ...formData, client_name: e.target.value }) + } + placeholder="My Awesome App" + required + /> +
+ +
+ +
+ setRedirectUriInput(e.target.value)} + placeholder="https://myapp.com/callback" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addRedirectUri(); + } + }} + /> + +
+
+ {formData.redirect_uris.map((uri, idx) => ( + + {uri} + + + ))} +
+

+ OAuth2 인증 ν›„ λ¦¬λ””λ ‰μ…˜λ  URI λͺ©λ‘μž…λ‹ˆλ‹€. +

+
+ +
+ + + setFormData({ ...formData, scope: e.target.value }) + } + placeholder="openid profile email" + /> +

+ Space-separated scopes. +

+
+ +
+
+ +
+ + + +
+
+ +
+ + +
+
+ +
+ + + + +
+
+
+ ); +} + +export default TenantRelyingPartyCreatePage; diff --git a/adminfront/src/features/tenants/routes/TenantRelyingPartyDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantRelyingPartyDetailPage.tsx new file mode 100644 index 00000000..5f0e4abb --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantRelyingPartyDetailPage.tsx @@ -0,0 +1,126 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Copy, ShieldCheck } from "lucide-react"; +import { Link, useParams } 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 { fetchRelyingParty } from "../../../lib/adminApi"; + +function TenantRelyingPartyDetailPage() { + const { tenantId, id } = useParams<{ tenantId: string; id: string }>(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["relyingParty", id], + queryFn: () => fetchRelyingParty(id!), + enabled: !!id, + }); + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + alert("λ³΅μ‚¬λ˜μ—ˆμŠ΅λ‹ˆλ‹€."); + }; + + if (isLoading) return
Loading...
; + if (error) return
Error loading app details.
; + + const { relyingParty, oauth2Config } = data!; + + return ( +
+
+
+
+ + + Relying Parties + + / + {relyingParty.name} +
+

{relyingParty.name}

+

+ {relyingParty.description || "상세 섀정을 ν™•μΈν•˜κ³  κ΄€λ¦¬ν•©λ‹ˆλ‹€."} +

+
+ + + Keto Protected + +
+ +
+ + + OAuth2 Credentials + 연동에 ν•„μš”ν•œ ν΄λΌμ΄μ–ΈνŠΈ μ •λ³΄μž…λ‹ˆλ‹€. + + +
+

Client ID

+
+ {oauth2Config.client_id} + +
+
+ +
+

Client Secret

+
+ + {oauth2Config.client_secret || (oauth2Config.metadata?.client_secret as string) || "********"} + + {(oauth2Config.client_secret || oauth2Config.metadata?.client_secret) && ( + + )} +
+

+ * Secret은 생성 μ‹œμ μ—λ§Œ λ…ΈμΆœλ˜κ±°λ‚˜ 메타데이터에 μ•”ν˜Έν™”λ˜μ–΄ μ €μž₯될 수 μžˆμŠ΅λ‹ˆλ‹€. +

+
+
+
+ + + + Configuration + OAuth2 λ™μž‘ μ„€μ • + + +
+

Redirect URIs

+
    + {(oauth2Config.redirect_uris || []).map((uri, i) => ( +
  • {uri}
  • + ))} +
+
+
+

Allowed Scopes

+
+ {(oauth2Config.scope || "").split(" ").filter(Boolean).map(s => ( + {s} + ))} +
+
+
+

Auth Method

+ {oauth2Config.token_endpoint_auth_method} +
+
+
+
+
+ ); +} + +export default TenantRelyingPartyDetailPage; diff --git a/adminfront/src/features/tenants/routes/TenantRelyingPartyListPage.tsx b/adminfront/src/features/tenants/routes/TenantRelyingPartyListPage.tsx new file mode 100644 index 00000000..11be121f --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantRelyingPartyListPage.tsx @@ -0,0 +1,155 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { Pencil, Plus, RefreshCw, Trash2, Share2 } from "lucide-react"; +import { Link, useNavigate, 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 { deleteRelyingParty, fetchRelyingParties } from "../../../lib/adminApi"; + +function TenantRelyingPartyListPage() { + const { tenantId } = useParams<{ tenantId: string }>(); + const navigate = useNavigate(); + + const query = useQuery({ + queryKey: ["relyingParties", tenantId], + queryFn: () => fetchRelyingParties(tenantId!), + enabled: !!tenantId, + }); + + const deleteMutation = useMutation({ + mutationFn: (clientId: string) => deleteRelyingParty(clientId), + onSuccess: () => { + query.refetch(); + }, + }); + + const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response + ?.data?.error; + const fallbackError = + !errorMsg && query.isError ? "μ•± λͺ©λ‘ μ‘°νšŒμ— μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€." : null; + + const items = query.data ?? []; + + const handleDelete = (clientId: string, name: string) => { + if (!window.confirm(`μ•± "${name}"λ₯Ό μ‚­μ œν• κΉŒμš”?`)) { + return; + } + deleteMutation.mutate(clientId); + }; + + return ( + + +
+ Relying Parties (Apps) + + 이 ν…Œλ„ŒνŠΈμ— λ“±λ‘λœ OAuth2/OIDC μ• ν”Œλ¦¬μΌ€μ΄μ…˜μž…λ‹ˆλ‹€. + +
+
+ + +
+
+ + {(errorMsg || fallbackError) && ( +
+ {errorMsg ?? fallbackError} +
+ )} + + + + + CLIENT ID + NAME + DESCRIPTION + UPDATED + ACTIONS + + + + {query.isLoading && ( + + λ‘œλ”© 쀑... + + )} + {!query.isLoading && items.length === 0 && ( + + + 아직 λ“±λ‘λœ 앱이 μ—†μŠ΅λ‹ˆλ‹€. + + + )} + {items.map((rp) => ( + + {rp.clientId} + +
+ + {rp.name} +
+
+ {rp.description || "-"} + + {rp.updatedAt + ? new Date(rp.updatedAt).toLocaleString("ko-KR") + : "-"} + + +
+ + +
+
+
+ ))} +
+
+
+
+ ); +} + +export default TenantRelyingPartyListPage; diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index a4e57f05..8acb7e32 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -36,7 +36,7 @@ func migrateSchemas(db *gorm.DB) error { &domain.User{}, &domain.ApiKey{}, &domain.IdentityProviderConfig{}, - // &domain.RelyingParty{}, // TODO: Uncomment when model is ready + &domain.RelyingParty{}, // &domain.UserConsent{}, // TODO: Uncomment when model is ready ) } diff --git a/backend/internal/bootstrap/keto_sync.go b/backend/internal/bootstrap/keto_sync.go new file mode 100644 index 00000000..676451ce --- /dev/null +++ b/backend/internal/bootstrap/keto_sync.go @@ -0,0 +1,52 @@ +package bootstrap + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "context" + "log/slog" + + "gorm.io/gorm" +) + +// SyncKetoRelations synchronizes all existing DB users and tenants to Ory Keto. +// This ensures data consistency for existing data when ReBAC is introduced. +func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error { + slog.Info("πŸš€ Starting Keto ReBAC relation synchronization...") + ctx := context.Background() + + // 1. Sync All Tenants (Ensure they exist in Keto if needed) + var tenants []domain.Tenant + if err := db.Find(&tenants).Error; err != nil { + return err + } + slog.Info("Syncing tenants to Keto", "count", len(tenants)) + for _, t := range tenants { + if t.ParentID != nil { + _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID) + } + } + + // 2. Sync All Users + var users []domain.User + if err := db.Find(&users).Error; err != nil { + return err + } + slog.Info("Syncing users to Keto", "count", len(users)) + for _, u := range users { + // Membership + if u.TenantID != nil { + _ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID) + } + + // Roles + if u.Role == domain.RoleSuperAdmin { + _ = keto.CreateRelation(ctx, "System", "global", "super_admins", u.ID) + } else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil { + _ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", u.ID) + } + } + + slog.Info("βœ… Keto ReBAC synchronization completed.") + return nil +} diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go new file mode 100644 index 00000000..02fa3663 --- /dev/null +++ b/backend/internal/domain/hydra_models.go @@ -0,0 +1,38 @@ +package domain + +import "time" + +type HydraClient struct { + ClientID string `json:"client_id"` + ClientName string `json:"client_name,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` // Added + RedirectURIs []string `json:"redirect_uris,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type HydraConsentRequest struct { + Challenge string `json:"challenge"` + RequestedScope []string `json:"requested_scope"` + RequestedAudience []string `json:"requested_access_token_audience"` + Skip bool `json:"skip"` + Subject string `json:"subject"` + Client HydraClient `json:"client"` +} + +type HydraConsentSession struct { + ConsentRequestID string `json:"consent_request_id,omitempty"` + Subject string `json:"subject,omitempty"` + GrantedScope []string `json:"grant_scope,omitempty"` + GrantedAudience []string `json:"grant_access_token_audience,omitempty"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for,omitempty"` + AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"` + RequestedAt *time.Time `json:"requested_at,omitempty"` + HandledAt *time.Time `json:"handled_at,omitempty"` + Client HydraClient `json:"client,omitempty"` + ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"` +} diff --git a/backend/internal/domain/relying_party.go b/backend/internal/domain/relying_party.go new file mode 100644 index 00000000..645b3dee --- /dev/null +++ b/backend/internal/domain/relying_party.go @@ -0,0 +1,26 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +// RelyingParty represents an OAuth2 Client owner by a Tenant. +// It maps 1:1 to a Hydra Client. +type RelyingParty struct { + ClientID string `gorm:"primaryKey" json:"clientId"` // Maps to Hydra Client ID + TenantID string `gorm:"index;not null" json:"tenantId"` + Name string `json:"name"` // Display name (can be same as Hydra Client Name) + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // We don't store OAuth2 specific config here (redirect_uris, etc.) + // those are fetched from Hydra on demand. +} + +func (rp *RelyingParty) TableName() string { + return "relying_parties" +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index a5606591..bac91e55 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3424,6 +3424,53 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { } func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK", + "APP_ENV", os.Getenv("APP_ENV"), + "GO_ENV", os.Getenv("GO_ENV"), + "X-Test-Role", c.Get("X-Test-Role"), + ) + slog.Info("πŸš€ [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method()) + // [Dev Only] Mock Role Bypass + appEnv := strings.ToLower(os.Getenv("APP_ENV")) + mockRole := c.Get("X-Test-Role") + if mockRole == "" { + mockRole = c.Get("X-Mock-Role") + } + + // Always log in development to see what's happening + if appEnv == "dev" || appEnv == "development" || appEnv == "" { + slog.Info("πŸ” [AUTH_DEBUG] Checking mock role", + "env", appEnv, + "mockRole", mockRole, + "X-Test-Role", c.Get("X-Test-Role"), + "X-Mock-Role", c.Get("X-Mock-Role"), + ) + } + + // If in dev mode and we have a mock role, bypass Kratos + if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" { + slog.Info("πŸ”‘ [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole) + mockProfile := &domain.UserProfileResponse{ + ID: "00000000-0000-0000-0000-000000000000", + Email: "mock@hmac.kr", + Name: "Dev Mock User", + Role: mockRole, + } + if tid := c.Get("X-Tenant-ID"); tid != "" { + mockProfile.TenantID = &tid + } + return mockProfile, nil + } + + // Mock bypass failed - log headers for debugging if in dev + if appEnv == "dev" || appEnv == "development" || appEnv == "" { + slog.Warn("⚠️ [DEBUG] Mock auth bypass failed", + "appEnv", appEnv, + "X-Test-Role", c.Get("X-Test-Role"), + "X-Mock-Role", c.Get("X-Mock-Role"), + "path", c.Path()) + } + var profile *domain.UserProfileResponse var err error @@ -3438,7 +3485,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe } if err != nil || profile == nil { - return nil, errors.New("invalid session") + return nil, errors.New("invalid session (trace:resolve_profile)") } // [New] Enrich with Local DB (Roles, TenantID, etc.) diff --git a/backend/internal/handler/relying_party_handler.go b/backend/internal/handler/relying_party_handler.go new file mode 100644 index 00000000..e4a12991 --- /dev/null +++ b/backend/internal/handler/relying_party_handler.go @@ -0,0 +1,111 @@ +package handler + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/service" + "github.com/gofiber/fiber/v2" + "log/slog" +) + +type RelyingPartyHandler struct { + Service service.RelyingPartyService +} + +func NewRelyingPartyHandler(s service.RelyingPartyService) *RelyingPartyHandler { + return &RelyingPartyHandler{Service: s} +} + +func (h *RelyingPartyHandler) Create(c *fiber.Ctx) error { + tenantID := c.Params("tenantId") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + } + + var req domain.HydraClient + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + rp, err := h.Service.Create(c.Context(), tenantID, req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(rp) +} + +func (h *RelyingPartyHandler) ListAll(c *fiber.Ctx) error { + profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse) + if !ok { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized: user profile not found in context"}) + } + + var rps []domain.RelyingParty + var err error + + if profile.Role == domain.RoleSuperAdmin { + rps, err = h.Service.ListAll(c.Context()) + } else if profile.Role == domain.RoleTenantAdmin && profile.TenantID != nil { + rps, err = h.Service.List(c.Context(), *profile.TenantID) + } else { + slog.Warn("Forbidden access to all applications", "userID", profile.ID, "role", profile.Role) + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "forbidden: insufficient role to list all applications"}) + } + + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(rps) +} + +func (h *RelyingPartyHandler) List(c *fiber.Ctx) error { + tenantID := c.Params("tenantId") + if tenantID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"}) + } + + rps, err := h.Service.List(c.Context(), tenantID) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(rps) +} + +func (h *RelyingPartyHandler) Get(c *fiber.Ctx) error { + id := c.Params("id") + rp, hydraClient, err := h.Service.Get(c.Context(), id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "relying party not found"}) + } + + return c.JSON(fiber.Map{ + "relyingParty": rp, + "oauth2Config": hydraClient, + }) +} + +func (h *RelyingPartyHandler) Update(c *fiber.Ctx) error { + id := c.Params("id") + var req domain.HydraClient + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid request body"}) + } + + rp, err := h.Service.Update(c.Context(), id, req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(rp) +} + +func (h *RelyingPartyHandler) Delete(c *fiber.Ctx) error { + id := c.Params("id") + if err := h.Service.Delete(c.Context(), id); err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.SendStatus(fiber.StatusNoContent) +} diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index c3956871..b67b7358 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -55,12 +55,13 @@ type userListResponse struct { } func (h *UserHandler) ListUsers(c *fiber.Ctx) error { - if h.KratosAdmin == nil { - return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "identity provider not available"}) - } - // [New] Get requester profile from middleware - requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) + var requesterRole string + var requesterCompany string + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + requesterRole = profile.Role + requesterCompany = profile.CompanyCode + } limit := c.QueryInt("limit", 50) offset := c.QueryInt("offset", 0) @@ -73,52 +74,82 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error { offset = 0 } + // 1. Try Kratos First identities, err := h.KratosAdmin.ListIdentities(c.Context()) + if err == nil { + filtered := make([]service.KratosIdentity, 0, len(identities)) + searchLower := strings.ToLower(search) + + for _, identity := range identities { + email := strings.ToLower(extractTraitString(identity.Traits, "email")) + name := strings.ToLower(extractTraitString(identity.Traits, "name")) + compCode := extractTraitString(identity.Traits, "companyCode") + + // Tenant Admin filtering + if requesterRole == domain.RoleTenantAdmin { + if requesterCompany == "" || compCode != requesterCompany { + continue + } + } + + // Search filtering + if search != "" { + if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) { + continue + } + } + filtered = append(filtered, identity) + } + + total := int64(len(filtered)) + if offset > len(filtered) { + offset = len(filtered) + } + end := offset + limit + if end > len(filtered) { + end = len(filtered) + } + + items := make([]userSummary, 0, end-offset) + for _, identity := range filtered[offset:end] { + summary := h.mapIdentitySummary(c.Context(), identity) + items = append(items, summary) + } + + return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) + } + + // 2. Fallback to Local DB if Kratos is down (Development only recommended) + slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err) + + // Fetch from UserRepo + users, total, err := h.UserRepo.List(c.Context(), offset, limit, search) if err != nil { - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"}) } - filtered := make([]service.KratosIdentity, 0, len(identities)) - searchLower := strings.ToLower(search) - - for _, identity := range identities { - email := strings.ToLower(extractTraitString(identity.Traits, "email")) - name := strings.ToLower(extractTraitString(identity.Traits, "name")) - compCode := extractTraitString(identity.Traits, "companyCode") - - // 1. Tenant Admin filtering - if requester != nil && requester.Role == domain.RoleTenantAdmin { - if requester.CompanyCode == "" || compCode != requester.CompanyCode { - continue // Skip users from other tenants - } - } - - // 2. Search filtering - if search != "" { - if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) { - continue - } - } - - filtered = append(filtered, identity) + items := make([]userSummary, 0, len(users)) + for _, u := range users { + items = append(items, userSummary{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + Phone: u.Phone, + Role: u.Role, + Status: u.Status, + CompanyCode: u.CompanyCode, + Department: u.Department, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + UpdatedAt: u.UpdatedAt.Format(time.RFC3339), + }) } - total := int64(len(filtered)) - if offset > len(filtered) { - offset = len(filtered) - } - end := offset + limit - if end > len(filtered) { - end = len(filtered) - } - - items := make([]userSummary, 0, end-offset) - for _, identity := range filtered[offset:end] { - summary := h.mapIdentitySummary(c.Context(), identity) - items = append(items, summary) - } - - return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total}) + return c.JSON(userListResponse{ + Items: items, + Total: total, + Limit: limit, + Offset: offset, + }) } func (h *UserHandler) GetUser(c *fiber.Ctx) error { diff --git a/backend/internal/middleware/api_key_auth.go b/backend/internal/middleware/api_key_auth.go index 13566a97..555e994c 100644 --- a/backend/internal/middleware/api_key_auth.go +++ b/backend/internal/middleware/api_key_auth.go @@ -101,7 +101,7 @@ func validateScope(method, path string, rawScopes string) bool { } // 3. ν…Œλ„ŒνŠΈ 관리 κ΄€λ ¨ (tenant:*) - if strings.Contains(path, "/admin/tenants") { + if strings.Contains(path, "/admin/tenants") || strings.Contains(path, "/admin/relying-parties") { if method == fiber.MethodGet { return scopeMap["tenant:read"] } diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 01dda4b5..5d8bd0d6 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -18,16 +18,14 @@ type RBACConfig struct { // RequireKetoPermission enforces permissions using Ory Keto (ReBAC) func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.Handler { return func(c *fiber.Ctx) error { - // Bypass if already authenticated via API Key - if c.Locals("apiKeyName") != nil { - return c.Next() - } - profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_keto)"}) } + // Store profile in locals for further use in handlers + c.Locals("user_profile", profile) + // Super Admin bypass if profile.Role == domain.RoleSuperAdmin { return c.Next() @@ -65,10 +63,13 @@ func RequireRole(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ - "error": "unauthorized: " + err.Error(), + "error": "unauthorized (trace:rbac_role): " + err.Error(), }) } + // Store profile in locals for further use in handlers + c.Locals("user_profile", profile) + // Super Admin always has access if profile.Role == domain.RoleSuperAdmin { return c.Next() @@ -112,9 +113,12 @@ func RequireTenantMatch(config RBACConfig) fiber.Handler { profile, err := config.AuthHandler.GetEnrichedProfile(c) if err != nil { - return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized"}) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthorized (trace:rbac_match)"}) } + // Store profile in locals for further use in handlers + c.Locals("user_profile", profile) + // Super Admin bypass if profile.Role == domain.RoleSuperAdmin { return c.Next() diff --git a/backend/internal/repository/relying_party_repository.go b/backend/internal/repository/relying_party_repository.go new file mode 100644 index 00000000..f36b0980 --- /dev/null +++ b/backend/internal/repository/relying_party_repository.go @@ -0,0 +1,61 @@ +package repository + +import ( + "baron-sso-backend/internal/domain" + "context" + + "gorm.io/gorm" +) + +type RelyingPartyRepository interface { + Create(ctx context.Context, rp *domain.RelyingParty) error + Update(ctx context.Context, rp *domain.RelyingParty) error + Delete(ctx context.Context, clientID string) error + FindByID(ctx context.Context, clientID string) (*domain.RelyingParty, error) + ListByTenantID(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) + ListAll(ctx context.Context) ([]domain.RelyingParty, error) +} + +func (r *relyingPartyRepository) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { + var rps []domain.RelyingParty + if err := r.db.WithContext(ctx).Find(&rps).Error; err != nil { + return nil, err + } + return rps, nil +} + +type relyingPartyRepository struct { + db *gorm.DB +} + +func NewRelyingPartyRepository(db *gorm.DB) RelyingPartyRepository { + return &relyingPartyRepository{db: db} +} + +func (r *relyingPartyRepository) Create(ctx context.Context, rp *domain.RelyingParty) error { + return r.db.WithContext(ctx).Create(rp).Error +} + +func (r *relyingPartyRepository) Update(ctx context.Context, rp *domain.RelyingParty) error { + return r.db.WithContext(ctx).Save(rp).Error +} + +func (r *relyingPartyRepository) Delete(ctx context.Context, clientID string) error { + return r.db.WithContext(ctx).Delete(&domain.RelyingParty{}, "client_id = ?", clientID).Error +} + +func (r *relyingPartyRepository) FindByID(ctx context.Context, clientID string) (*domain.RelyingParty, error) { + var rp domain.RelyingParty + if err := r.db.WithContext(ctx).First(&rp, "client_id = ?", clientID).Error; err != nil { + return nil, err + } + return &rp, nil +} + +func (r *relyingPartyRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { + var rps []domain.RelyingParty + if err := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID).Find(&rps).Error; err != nil { + return nil, err + } + return rps, nil +} diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go index 77c6f15a..e27d51f0 100644 --- a/backend/internal/repository/user_repository.go +++ b/backend/internal/repository/user_repository.go @@ -13,6 +13,7 @@ type UserRepository interface { FindByEmail(ctx context.Context, email string) (*domain.User, error) FindByID(ctx context.Context, id string) (*domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) + List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) } type userRepository struct { @@ -54,3 +55,24 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d } return users, nil } + +func (r *userRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) { + var users []domain.User + var total int64 + db := r.db.WithContext(ctx).Model(&domain.User{}) + + if search != "" { + searchTerm := "%" + search + "%" + db = db.Where("email LIKE ? OR name LIKE ?", searchTerm, searchTerm) + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db.Offset(offset).Limit(limit).Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} diff --git a/backend/internal/service/relying_party_service.go b/backend/internal/service/relying_party_service.go new file mode 100644 index 00000000..8a9c3499 --- /dev/null +++ b/backend/internal/service/relying_party_service.go @@ -0,0 +1,155 @@ +package service + +import ( + "baron-sso-backend/internal/domain" + "baron-sso-backend/internal/repository" + "context" + "fmt" + "log/slog" +) + +type RelyingPartyService interface { + Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) + Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) + List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) + ListAll(ctx context.Context) ([]domain.RelyingParty, error) + ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) + Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) + Delete(ctx context.Context, clientID string) error +} + +func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { + return s.repo.ListAll(ctx) +} + +func (s *relyingPartyService) ListByTenantIDs(ctx context.Context, tenantIDs []string) ([]domain.RelyingParty, error) { + // Simple implementation for now, repository could be optimized with IN clause + var allRps []domain.RelyingParty + for _, tid := range tenantIDs { + rps, _ := s.repo.ListByTenantID(ctx, tid) + allRps = append(allRps, rps...) + } + return allRps, nil +} + +type relyingPartyService struct { + repo repository.RelyingPartyRepository + hydraService *HydraAdminService + ketoService KetoService +} + +func NewRelyingPartyService( + repo repository.RelyingPartyRepository, + hydraService *HydraAdminService, + ketoService KetoService, +) RelyingPartyService { + return &relyingPartyService{ + repo: repo, + hydraService: hydraService, + ketoService: ketoService, + } +} + +func (s *relyingPartyService) Create(ctx context.Context, tenantID string, client domain.HydraClient) (*domain.RelyingParty, error) { + // 1. Create Client in Hydra + // Ensure metadata contains tenant_id for reference + if client.Metadata == nil { + client.Metadata = make(map[string]interface{}) + } + client.Metadata["tenant_id"] = tenantID + + createdClient, err := s.hydraService.CreateClient(ctx, client) + if err != nil { + return nil, fmt.Errorf("failed to create hydra client: %w", err) + } + + // 2. Create Record in DB + rp := &domain.RelyingParty{ + ClientID: createdClient.ClientID, + TenantID: tenantID, + Name: createdClient.ClientName, + Description: "", // Hydra doesn't have description field standard, maybe in metadata? + } + + if err := s.repo.Create(ctx, rp); err != nil { + // Rollback: Delete Hydra Client + _ = s.hydraService.DeleteClient(ctx, createdClient.ClientID) + return nil, fmt.Errorf("failed to create relying party in db: %w", err) + } + + // 3. Create Relation in Keto + // RelyingParty:#parent_tenant@Tenant: + err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID) + if err != nil { + slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID) + // We don't rollback here, but we should probably have a background job to fix this. + // Or return error and let caller decide? For MVP, logging error is acceptable as per issue discussion (Eventual Consistency preferred). + } + + return rp, nil +} + +func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain.RelyingParty, *domain.HydraClient, error) { + // Get from DB + rp, err := s.repo.FindByID(ctx, clientID) + if err != nil { + return nil, nil, err + } + + // Get from Hydra + hydraClient, err := s.hydraService.GetClient(ctx, clientID) + if err != nil { + return nil, nil, err + } + + return rp, hydraClient, nil +} + +func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { + return s.repo.ListByTenantID(ctx, tenantID) +} + +func (s *relyingPartyService) Update(ctx context.Context, clientID string, client domain.HydraClient) (*domain.RelyingParty, error) { + // Update Hydra + updatedClient, err := s.hydraService.UpdateClient(ctx, clientID, client) + if err != nil { + return nil, err + } + + // Update DB + rp, err := s.repo.FindByID(ctx, clientID) + if err != nil { + return nil, err + } + rp.Name = updatedClient.ClientName + // Update other fields if necessary + + if err := s.repo.Update(ctx, rp); err != nil { + return nil, err + } + + return rp, nil +} + +func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error { + // Delete from DB + if err := s.repo.Delete(ctx, clientID); err != nil { + return err + } + + // Delete from Hydra + if err := s.hydraService.DeleteClient(ctx, clientID); err != nil { + slog.Error("Failed to delete hydra client", "error", err, "client_id", clientID) + // Proceeding... + } + + // Delete from Keto (Optional, but good practice to clean up) + // We might not know the tenant ID here without querying DB first, but if DB is deleted, we might miss it. + //Ideally, we should query DB first. + // But `DeleteRelation` requires specific object/relation/subject. + // If we want to delete ALL relations for this object, Keto API supports that? + // `DeleteRelation` in our service wrapper is specific. + // We can skip explicit Keto deletion for now as orphaned tuples are less critical than orphaned resources. + + return nil +}