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 (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ 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 (
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ 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
+}