diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 89ba6219..2117b8fe 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -8,7 +8,7 @@ import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; -import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; +import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantListPage from "../features/tenants/routes/TenantListPage"; @@ -48,7 +48,7 @@ export const router = createBrowserRouter( element: , children: [ { index: true, element: }, - { path: "admins", element: }, + { path: "permissions", element: }, { path: "organization", element: }, { path: "schema", element: }, ], diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx new file mode 100644 index 00000000..36f0f260 --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -0,0 +1,549 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { + Crown, + Plus, + Search, + ShieldCheck, + Trash2, + UserPlus, + Users, +} from "lucide-react"; +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { Input } from "../../../components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { + addTenantAdmin, + addTenantOwner, + fetchTenantAdmins, + fetchTenantOwners, + fetchUsers, + removeTenantAdmin, + removeTenantOwner, +} from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; + +type DialogMode = "owner" | "admin"; + +export function TenantAdminsAndOwnersTab() { + const { tenantId } = useParams<{ tenantId: string }>(); + const queryClient = useQueryClient(); + const [searchTerm, setSearchTerm] = useState(""); + const [dialogMode, setDialogMode] = useState(null); + + if (!tenantId) return null; + + const ownersQuery = useQuery({ + queryKey: ["tenant-owners", tenantId], + queryFn: () => fetchTenantOwners(tenantId), + enabled: !!tenantId, + }); + + const adminsQuery = useQuery({ + queryKey: ["tenant-admins", tenantId], + queryFn: () => fetchTenantAdmins(tenantId), + enabled: !!tenantId, + }); + + const usersQuery = useQuery({ + queryKey: ["admin-users-search", searchTerm], + queryFn: () => fetchUsers(20, 0, searchTerm), + enabled: dialogMode !== null && searchTerm.length >= 2, + }); + + const addOwnerMutation = useMutation({ + mutationFn: (userId: string) => addTenantOwner(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + toast.success( + t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."), + ); + setSearchTerm(""); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeOwnerMutation = useMutation({ + mutationFn: (userId: string) => removeTenantOwner(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] }); + toast.success( + t( + "msg.admin.tenants.owners.remove_success", + "소유자 권한이 회수되었습니다.", + ), + ); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const addAdminMutation = useMutation({ + mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + toast.success( + t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), + ); + setSearchTerm(""); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeAdminMutation = useMutation({ + mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); + toast.success( + t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), + ); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const handleAddUser = (userId: string) => { + if (dialogMode === "owner") { + addOwnerMutation.mutate(userId); + } else if (dialogMode === "admin") { + addAdminMutation.mutate(userId); + } + }; + + const handleRemoveOwner = (userId: string, userName: string) => { + if ( + window.confirm( + t( + "msg.admin.tenants.owners.remove_confirm", + "소유자를 삭제하시겠습니까?", + { name: userName }, + ), + ) + ) { + removeOwnerMutation.mutate(userId); + } + }; + + const handleRemoveAdmin = (userId: string, userName: string) => { + if ( + window.confirm( + t( + "msg.admin.tenants.admins.remove_confirm", + "관리자를 삭제하시겠습니까?", + { name: userName }, + ), + ) + ) { + removeAdminMutation.mutate(userId); + } + }; + + const currentOwners = ownersQuery.data || []; + const currentAdmins = adminsQuery.data || []; + const searchResults = usersQuery.data?.items || []; + const isDialogOpen = dialogMode !== null; + + const dialogTitle = + dialogMode === "owner" + ? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가") + : t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가"); + + const dialogDescription = + dialogMode === "owner" + ? t( + "ui.admin.tenants.owners.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + ) + : t( + "ui.admin.tenants.admins.dialog_description", + "이름 또는 이메일로 사용자를 검색하세요.", + ); + + return ( +
+ {/* Owners Card */} + + +
+ + + {t("ui.admin.tenants.owners.title", "테넌트 소유자")} + + + {t( + "msg.admin.tenants.owners.subtitle", + "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.", + )} + +
+ +
+ +
+ + + + + {t("ui.admin.tenants.owners.table_name", "이름")} + + + {t("ui.admin.tenants.owners.table_email", "이메일")} + + + {t("ui.admin.tenants.owners.table_actions", "액션")} + + + + + {ownersQuery.isLoading ? ( + + +
+ + + ) : currentOwners.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.owners.empty", + "등록된 소유자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentOwners.map((owner) => ( + + +
+
+ {owner.name.charAt(0)} +
+ {owner.name} +
+
+ + {owner.email} + + + + +
+ )) + )} + +
+
+
+
+ + {/* Admins Card */} + + +
+ + + {t("ui.admin.tenants.admins.title", "테넌트 관리자")} + + + {t( + "msg.admin.tenants.admins.subtitle", + "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", + )} + +
+ +
+ +
+ + + + + {t("ui.admin.tenants.admins.table_name", "이름")} + + + {t("ui.admin.tenants.admins.table_email", "이메일")} + + + {t("ui.admin.tenants.admins.table_actions", "액션")} + + + + + {adminsQuery.isLoading ? ( + + +
+ + + ) : currentAdmins.length === 0 ? ( + + +
+ +

+ {t( + "msg.admin.tenants.admins.empty", + "등록된 관리자가 없습니다.", + )} +

+
+
+
+ ) : ( + currentAdmins.map((admin) => ( + + +
+
+ {admin.name.charAt(0)} +
+ {admin.name} +
+
+ + {admin.email} + + + + +
+ )) + )} + +
+
+
+
+ + {/* Common Dialog for adding users */} + { + if (!open) { + setDialogMode(null); + setSearchTerm(""); + } + }} + > + + + + {dialogTitle} + + {dialogDescription} + + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ {searchTerm.length < 2 ? ( +
+ +

+ {t( + "ui.admin.tenants.admins.dialog_search_hint", + "검색어를 입력해 주세요.", + )} +

+
+ ) : usersQuery.isLoading ? ( +
+
+
+ ) : searchResults.length === 0 ? ( +
+ {t( + "ui.admin.tenants.admins.dialog_no_results", + "검색 결과가 없습니다.", + )} +
+ ) : ( +
+ {searchResults.map((user) => { + const isAlreadyOwner = currentOwners.some( + (o) => o.id === user.id, + ); + const isAlreadyAdmin = currentAdmins.some( + (a) => a.id === user.id, + ); + const isAlreadyMember = + dialogMode === "owner" + ? isAlreadyOwner + : isAlreadyAdmin; + + return ( +
+
+
+ {user.name.charAt(0)} +
+
+ + {user.name} + + + {user.email} + +
+
+ +
+ ); + })} +
+ )} +
+
+ +
+
+ ); +} + +export default TenantAdminsAndOwnersTab; diff --git a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx deleted file mode 100644 index 87601586..00000000 --- a/adminfront/src/features/tenants/routes/TenantAdminsTab.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { AxiosError } from "axios"; -import { - Plus, - Search, - ShieldCheck, - Trash2, - UserPlus, - Users, -} from "lucide-react"; -import { useState } from "react"; -import { useParams } from "react-router-dom"; -import { toast } from "sonner"; -import { Badge } from "../../../components/ui/badge"; -import { Button } from "../../../components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../../../components/ui/card"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../../../components/ui/dialog"; -import { Input } from "../../../components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../../../components/ui/table"; -import { - addTenantAdmin, - fetchTenantAdmins, - fetchUsers, - removeTenantAdmin, -} from "../../../lib/adminApi"; -import { t } from "../../../lib/i18n"; - -export function TenantAdminsTab() { - const { tenantId } = useParams<{ tenantId: string }>(); - const queryClient = useQueryClient(); - const [searchTerm, setSearchTerm] = useState(""); - const [isDialogOpen, setIsAddDialogOpen] = useState(false); - - if (!tenantId) return null; - - // 현재 관리자 목록 조회 - const adminsQuery = useQuery({ - queryKey: ["tenant-admins", tenantId], - queryFn: () => fetchTenantAdmins(tenantId), - enabled: !!tenantId, - }); - - // 사용자 검색 조회 (2자 이상 입력 시) - const usersQuery = useQuery({ - queryKey: ["admin-users-search", searchTerm], - queryFn: () => fetchUsers(20, 0, searchTerm), - enabled: isDialogOpen && searchTerm.length >= 2, - }); - - const addMutation = useMutation({ - mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); - toast.success( - t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."), - ); - setSearchTerm(""); - }, - onError: (err: AxiosError<{ error?: string }>) => { - toast.error( - err.response?.data?.error || - t("msg.common.error", "오류가 발생했습니다."), - ); - }, - }); - - const removeMutation = useMutation({ - mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] }); - toast.success( - t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."), - ); - }, - onError: (err: AxiosError<{ error?: string }>) => { - toast.error( - err.response?.data?.error || - t("msg.common.error", "오류가 발생했습니다."), - ); - }, - }); - - const handleAddAdmin = (userId: string) => { - addMutation.mutate(userId); - }; - - const handleRemoveAdmin = (userId: string, userName: string) => { - if ( - window.confirm( - t( - "msg.admin.tenants.admins.remove_confirm", - "관리자를 삭제하시겠습니까?", - { name: userName }, - ), - ) - ) { - removeMutation.mutate(userId); - } - }; - - const currentAdmins = adminsQuery.data || []; - const searchResults = usersQuery.data?.items || []; - - return ( -
- - -
- - - {t("ui.admin.tenants.admins.title", "테넌트 관리자")} - - - {t( - "msg.admin.tenants.admins.subtitle", - "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.", - )} - -
- - { - setIsAddDialogOpen(open); - if (!open) setSearchTerm(""); - }} - > - - - - - - - {t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가")} - - - {t( - "ui.admin.tenants.admins.dialog_description", - "이름 또는 이메일로 사용자를 검색하세요.", - )} - - - -
-
- - setSearchTerm(e.target.value)} - /> -
- -
- {searchTerm.length < 2 ? ( -
- -

- {t( - "ui.admin.tenants.admins.dialog_search_hint", - "검색어를 입력해 주세요.", - )} -

-
- ) : usersQuery.isLoading ? ( -
-
-
- ) : searchResults.length === 0 ? ( -
- {t( - "ui.admin.tenants.admins.dialog_no_results", - "검색 결과가 없습니다.", - )} -
- ) : ( -
- {searchResults.map((user) => { - const isAlreadyAdmin = currentAdmins.some( - (a) => a.id === user.id, - ); - return ( -
-
-
- {user.name.charAt(0)} -
-
- - {user.name} - - - {user.email} - -
-
- -
- ); - })} -
- )} -
-
- -
-
- - -
- - - - - {t("ui.admin.tenants.admins.table_name", "이름")} - - - {t("ui.admin.tenants.admins.table_email", "이메일")} - - - {t("ui.admin.tenants.admins.table_actions", "액션")} - - - - - {adminsQuery.isLoading ? ( - - -
- - - ) : currentAdmins.length === 0 ? ( - - -
- -

- {t( - "msg.admin.tenants.admins.empty", - "등록된 관리자가 없습니다.", - )} -

-
-
-
- ) : ( - currentAdmins.map((admin) => ( - - -
-
- {admin.name.charAt(0)} -
- {admin.name} -
-
- - {admin.email} - - - - -
- )) - )} - -
-
-
-
-
- ); -} - -export default TenantAdminsTab; diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index d30b96a1..44bad3fc 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -17,7 +17,7 @@ function TenantDetailPage() { }); const isFederationTab = location.pathname.includes("/federation"); - const isAdminTab = location.pathname.includes("/admins"); + const isPermissionsTab = location.pathname.includes("/permissions"); const isOrganizationTab = location.pathname.includes("/organization"); return ( @@ -59,7 +59,7 @@ function TenantDetailPage() { to={`/tenants/${tenantId}`} className={`px-6 py-3 text-sm font-medium transition-colors relative ${ !isFederationTab && - !isAdminTab && + !isPermissionsTab && !location.pathname.includes("/schema") && !isOrganizationTab ? "text-primary border-b-2 border-primary" @@ -79,14 +79,14 @@ function TenantDetailPage() { {t("ui.admin.tenants.detail.tab_federation", "외부 연동")} - {t("ui.admin.tenants.detail.tab_admins", "관리자 설정")} + {t("ui.admin.tenants.detail.tab_permissions", "권한")} - + + diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index d660dbd1..f4e98948 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -287,10 +287,16 @@ function UserDetailPage() { {...register("role")} > - + + diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 9e56f38a..6438190d 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -245,7 +245,7 @@ function UserListPage() { - {t(`ui.common.role.${user.role}`, user.role)} + {t(`ui.admin.role.${user.role}`, user.role)} diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index 1d68ad73..25271b22 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -167,6 +167,21 @@ export async function removeTenantAdmin(tenantId: string, userId: string) { await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`); } +export async function fetchTenantOwners(tenantId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/owners`, + ); + return data; +} + +export async function addTenantOwner(tenantId: string, userId: string) { + await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`); +} + +export async function removeTenantOwner(tenantId: string, userId: string) { + await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`); +} + // Group Management export type GroupMember = { id: string; diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 65edff3f..6b2ec8ff 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -703,7 +703,7 @@ view_audit_logs = "View Audit Logs" rp_admin = "RP ADMIN" super_admin = "SUPER ADMIN" tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +user = "TENANT MEMBER" [ui.admin.tenants] add = "Tenant Add" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 330d9947..d82a208a 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -772,10 +772,10 @@ policy_gate = "정책 게이트" total_tenants = "전체 테넌트" [ui.admin.role] -rp_admin = "RP ADMIN" -super_admin = "SUPER ADMIN" -tenant_admin = "TENANT ADMIN" -tenant_member = "TENANT MEMBER" +rp_admin = "서비스 관리자 (RP Admin)" +super_admin = "시스템 관리자 (Super Admin)" +tenant_admin = "테넌트 관리자 (Tenant Admin)" +user = "일반 사용자 (Tenant Member)" [ui.admin.tenants] add = "테넌트 추가" diff --git a/adminfront/tests/owners.spec.ts b/adminfront/tests/owners.spec.ts new file mode 100644 index 00000000..24cffac9 --- /dev/null +++ b/adminfront/tests/owners.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test"; + +test.describe("Tenant Owners Management", () => { + test.beforeEach(async ({ page }) => { + // Authenticate + await page.addInitScript(() => { + const authority = "http://localhost:5000/oidc"; + const client_id = "adminfront"; + const key = `oidc.user:${authority}:${client_id}`; + const authData = { + access_token: "fake-token", + token_type: "Bearer", + profile: { + sub: "admin-user", + name: "Admin User", + email: "admin@example.com", + }, + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + window.localStorage.setItem(key, JSON.stringify(authData)); + }); + + // Mock OIDC config to avoid redirects + await page.route( + "**/oidc/.well-known/openid-configuration", + async (route) => { + await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); + }, + ); + + // Mock tenant details + await page.route("**/api/v1/admin/tenants/tenant-1**", async (route) => { + await route.fulfill({ + json: { + id: "tenant-1", + name: "Test Tenant", + slug: "test-tenant", + status: "active", + type: "COMPANY", + }, + }); + }); + }); + + test("should list tenant owners", async ({ page }) => { + // Mock owners list + await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => { + await route.fulfill({ + json: [ + { id: "owner-1", name: "Owner One", email: "owner1@example.com" }, + ], + }); + }); + + await page.goto("/tenants/tenant-1/owners"); + + // Check if the page title and the owner are visible + await expect(page.locator("h3")).toContainText("테넌트 소유자"); + await expect(page.locator("table")).toContainText("Owner One"); + await expect(page.locator("table")).toContainText("owner1@example.com"); + }); + + test("should add a new owner", async ({ page }) => { + // Mock owners list (initially empty) + await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ json: [] }); + } else if (route.request().method() === "POST") { + await route.fulfill({ status: 200 }); + } + }); + + // Mock users search + await page.route("**/api/v1/admin/users?**", async (route) => { + await route.fulfill({ + json: { + items: [ + { id: "user-2", name: "User Two", email: "user2@example.com" }, + ], + total: 1, + }, + }); + }); + + await page.goto("/tenants/tenant-1/owners"); + + // Click add button + await page.click('button:has-text("소유자 추가")'); + + // Search for user + await page.fill('input[placeholder*="사용자 검색"]', "User Two"); + + // Wait for results and add - using a more specific selector to target the button in the dialog + const addButton = page.locator('role=dialog').getByRole('button', { name: '추가' }); + await addButton.click(); + + // Verify toast or mutation (in a real app, the list would refresh) + // Here we just check if the dialog was closed or toast appears + // toast is shown on success + }); +}); diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index dd930a94..777d51d7 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -566,6 +566,9 @@ func main() { admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins) admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin) admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin) + admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners) + admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner) + admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner) // Organization & Org-Chart Management (Tenant Admin/Super Admin) org := admin.Group("/tenants/:tenantId/organization", requireAdmin) diff --git a/backend/internal/bootstrap/tenant_seed.go b/backend/internal/bootstrap/tenant_seed.go index e4bad4e8..7360afa7 100644 --- a/backend/internal/bootstrap/tenant_seed.go +++ b/backend/internal/bootstrap/tenant_seed.go @@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error { } slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) - tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil) + tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "") if err != nil { slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) return err diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 4d195458..1797b6a3 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3925,104 +3925,83 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error { } func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { - slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK", - "APP_ENV", os.Getenv("APP_ENV"), - "GO_ENV", os.Getenv("GO_ENV"), - "X-Test-Role", c.Get("X-Test-Role"), - ) - slog.Info("🚀 [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method()) - // [Dev Only] Mock Role Bypass appEnv := strings.ToLower(os.Getenv("APP_ENV")) + isDev := appEnv == "dev" || appEnv == "development" || appEnv == "" + mockRole := c.Get("X-Test-Role") if mockRole == "" { mockRole = c.Get("X-Mock-Role") } - // Always log in development to see what's happening - if appEnv == "dev" || appEnv == "development" || appEnv == "" { - slog.Info("🔍 [AUTH_DEBUG] Checking mock role", - "env", appEnv, - "mockRole", mockRole, - "X-Test-Role", c.Get("X-Test-Role"), - "X-Mock-Role", c.Get("X-Mock-Role"), - ) + token := h.getBearerToken(c) + cookie := c.Get("Cookie") + + var profile *domain.UserProfileResponse + var err error + cacheKey := "" + + // 1. Try to fetch real profile if token/cookie exists + if token != "" || cookie != "" { + // Try Redis Cache + if h.RedisService != nil && token != "" { + cacheKey = "cache:profile:token:" + token + cached, _ := h.RedisService.Get(cacheKey) + if cached != "" { + if json.Unmarshal([]byte(cached), &profile) == nil { + // Fall through to role override check + } + } + } + + if profile == nil { + // Fetch from Kratos (SoT) + if token != "" { + profile, err = h.getKratosProfile(token) + if err != nil && h.Hydra != nil { + // Fallback to Hydra introspection + profile, err = h.getHydraProfile(c.Context(), token) + } + } else if cookie != "" { + profile, err = h.getKratosProfileWithCookie(cookie) + } + } } - // If in dev mode and we have a mock role, bypass Kratos - if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" { - slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole) - mockProfile := &domain.UserProfileResponse{ + // 2. Role Override for real profile or fallback to Mock Profile + if profile != nil { + if isDev && mockRole != "" { + slog.Info("🔑 [AUTH_DEBUG] Overriding real profile role with mock role", + "email", profile.Email, "oldRole", profile.Role, "newRole", mockRole) + profile.Role = mockRole + } + } else if isDev && mockRole != "" { + slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole) + profile = &domain.UserProfileResponse{ ID: "00000000-0000-0000-0000-000000000000", Email: "mock@hmac.kr", Name: "Dev Mock User", Role: mockRole, } if tid := c.Get("X-Tenant-ID"); tid != "" { - mockProfile.TenantID = &tid - } - return mockProfile, nil - } - - // Mock bypass failed - log headers for debugging if in dev - if appEnv == "dev" || appEnv == "development" || appEnv == "" { - slog.Warn("⚠️ [DEBUG] Mock auth bypass failed", - "appEnv", appEnv, - "X-Test-Role", c.Get("X-Test-Role"), - "X-Mock-Role", c.Get("X-Mock-Role"), - "path", c.Path()) - } - - var profile *domain.UserProfileResponse - var err error - - token := h.getBearerToken(c) - cookie := c.Get("Cookie") - cacheKey := "" - - // 1. Try Redis Cache - if h.RedisService != nil { - if token != "" { - cacheKey = "cache:profile:token:" + token - } - // Cookie based caching skipped for simplicity/safety - - if cacheKey != "" { - cached, _ := h.RedisService.Get(cacheKey) - if cached != "" { - if json.Unmarshal([]byte(cached), &profile) == nil { - return profile, nil - } - } + profile.TenantID = &tid } } - // 2. Fetch from Kratos (SoT) - if token != "" { - profile, err = h.getKratosProfile(token) - } else { - if cookie != "" { - profile, err = h.getKratosProfileWithCookie(cookie) - } - } - - if err != nil || profile == nil { + if profile == nil { return nil, errors.New("invalid session (trace:resolve_profile)") } // 3. Post-Process (Defaults & Metadata Enrichment) - // Default Role if missing (migration safety) if profile.Role == "" { profile.Role = domain.RoleUser } // Fetch Tenant Metadata if missing - // Case A: Have TenantID from Kratos -> Fetch by ID if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { profile.Tenant = tenant } } - // Case B: Have CompanyCode but no TenantID -> Fetch by Slug if profile.Tenant == nil && profile.CompanyCode != "" { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { profile.Tenant = tenant @@ -4033,7 +4012,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe } // 4. Save to Redis Cache (Short TTL) - if h.RedisService != nil && cacheKey != "" { + if h.RedisService != nil && cacheKey != "" && err == nil { if data, err := json.Marshal(profile); err == nil { ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttl := 30 * time.Minute // Default TTL @@ -5060,12 +5039,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string] return nil } -func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { - identityID, traits, err := h.getKratosIdentity(sessionToken) +func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) { + intro, err := h.Hydra.IntrospectToken(ctx, token) if err != nil { + slog.Error("Hydra introspection failed", "error", err) return nil, err } + if !intro.Active { + slog.Warn("Hydra token is not active") + return nil, errors.New("token is not active") + } + slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID) + + // Fetch identity details from Kratos by subject (identityID) + identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject) + if err != nil || identity == nil { + slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject) + // Fallback to minimal profile if Kratos identity not found + return &domain.UserProfileResponse{ + ID: intro.Subject, + Email: "unknown@hydra.local", + Name: "Hydra User", + Role: domain.RoleUser, + }, nil + } + + return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil +} + +func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse { email, _ := traits["email"].(string) name, _ := traits["name"].(string) phone, _ := traits["phone_number"].(string) @@ -5101,8 +5104,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile profile.Metadata[k] = v } } + return profile +} - return profile, nil +func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { + identityID, traits, err := h.getKratosIdentity(sessionToken) + if err != nil { + return nil, err + } + return h.mapKratosIdentityToProfile(identityID, traits), nil } func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { @@ -5110,44 +5120,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro if err != nil { return nil, err } - - email, _ := traits["email"].(string) - name, _ := traits["name"].(string) - phone, _ := traits["phone_number"].(string) - dept, _ := traits["department"].(string) - affType, _ := traits["affiliationType"].(string) - compCode, _ := traits["companyCode"].(string) - role, _ := traits["role"].(string) - tenantID, _ := traits["tenant_id"].(string) - - profile := &domain.UserProfileResponse{ - ID: identityID, - Email: email, - Name: name, - Phone: h.formatPhoneForDisplay(phone), - Department: dept, - AffiliationType: affType, - CompanyCode: compCode, - Role: role, - Metadata: make(map[string]any), - } - - if tenantID != "" { - profile.TenantID = &tenantID - } - - coreTraits := map[string]bool{ - "email": true, "name": true, "phone_number": true, - "grade": true, "companyCode": true, "department": true, - "affiliationType": true, "role": true, "tenant_id": true, - } - for k, v := range traits { - if !coreTraits[k] { - profile.Metadata[k] = v - } - } - - return profile, nil + return h.mapKratosIdentityToProfile(identityID, traits), nil } // UpdateMe - Updates current user's profile with phone verification check diff --git a/backend/internal/handler/auth_handler_async_test.go b/backend/internal/handler/auth_handler_async_test.go index 35c13cf3..c7ff6139 100644 --- a/backend/internal/handler/auth_handler_async_test.go +++ b/backend/internal/handler/auth_handler_async_test.go @@ -140,7 +140,7 @@ type AsyncMockTenantService struct { mock.Mock } -func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { return nil, nil } diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go index aa7b7a66..37f5b57b 100644 --- a/backend/internal/handler/tenant_handler.go +++ b/backend/internal/handler/tenant_handler.go @@ -221,7 +221,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error { parentID = &pid } - tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID) + // Extract creator ID if present + creatorID := "" + if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok { + creatorID = profile.ID + } + + tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID) if err != nil { if strings.Contains(err.Error(), "already exists") { return errorJSON(c, fiber.StatusConflict, err.Error()) @@ -423,20 +429,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error { } userID := strings.TrimPrefix(rel.SubjectID, "User:") - // Fetch user details from Kratos - identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) - if err != nil { - admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"}) - continue - } + // Fetch user details - Try Kratos first, then local DB + name := "Unknown" + email := "Unknown" - name := "" - if n, ok := identity.Traits["name"].(string); ok { - name = n - } - email := "" - if e, ok := identity.Traits["email"].(string); ok { - email = e + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + } else if h.UserRepo != nil { + // Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos) + user, err := h.UserRepo.FindByID(c.Context(), userID) + if err == nil && user != nil { + name = user.Name + email = user.Email + } else if userID == "00000000-0000-0000-0000-000000000000" { + name = "Dev Mock User" + email = "mock@hmac.kr" + } } admins = append(admins, adminInfo{ @@ -464,6 +478,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error { Subject: "User:" + userID, Action: domain.KetoOutboxActionCreate, }) + // Also add as member for UI visibility/ReBAC logic + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "members", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) } return c.SendStatus(fiber.StatusOK) @@ -489,6 +511,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } +func (h *TenantHandler) ListOwners(c *fiber.Ctx) error { + tenantID := c.Params("id") + if tenantID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenant id is required") + } + + // Fetch owners from Keto + relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "") + if err != nil { + return errorJSON(c, fiber.StatusInternalServerError, err.Error()) + } + + type ownerInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + } + owners := []ownerInfo{} + + for _, rel := range relations { + if !strings.HasPrefix(rel.SubjectID, "User:") { + continue + } + userID := strings.TrimPrefix(rel.SubjectID, "User:") + + // Fetch user details - Try Kratos first, then local DB + name := "Unknown" + email := "Unknown" + + identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) + if err == nil && identity != nil { + if n, ok := identity.Traits["name"].(string); ok { + name = n + } + if e, ok := identity.Traits["email"].(string); ok { + email = e + } + } else if h.UserRepo != nil { + // Fallback to local DB + user, err := h.UserRepo.FindByID(c.Context(), userID) + if err == nil && user != nil { + name = user.Name + email = user.Email + } else if userID == "00000000-0000-0000-0000-000000000000" { + name = "Dev Mock User" + email = "mock@hmac.kr" + } + } + + owners = append(owners, ownerInfo{ + ID: userID, + Name: name, + Email: email, + }) + } + + return c.JSON(owners) +} + +func (h *TenantHandler) AddOwner(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "owners", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + // Also add as member for UI visibility/ReBAC logic + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "members", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionCreate, + }) + } + + return c.SendStatus(fiber.StatusOK) +} + +func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error { + tenantID := c.Params("id") + userID := c.Params("userId") + if tenantID == "" || userID == "" { + return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required") + } + + if h.KetoOutbox != nil { + _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenantID, + Relation: "owners", + Subject: "User:" + userID, + Action: domain.KetoOutboxActionDelete, + }) + } + + return c.SendStatus(fiber.StatusNoContent) +} + func mapTenantSummary(t domain.Tenant) tenantSummary { domains := make([]string, 0, len(t.Domains)) for _, d := range t.Domains { diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go index 80e89d73..b608b550 100644 --- a/backend/internal/handler/tenant_handler_test.go +++ b/backend/internal/handler/tenant_handler_test.go @@ -21,8 +21,8 @@ type MockTenantService struct { mock.Mock } -func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { - args := m.Called(ctx, name, slug, tenantType, description, domains, parentID) +func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { + args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -133,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) { } body, _ := json.Marshal(input) - mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)). + mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), ""). Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go index 0175974b..c1299116 100644 --- a/backend/internal/middleware/rbac.go +++ b/backend/internal/middleware/rbac.go @@ -59,8 +59,8 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber. c.Locals("tenant_id", objectID) } - // Check with Keto - allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) + // Check with Keto - add User: prefix to subject + allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation) if err != nil { slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) return errorJSON(c, fiber.StatusInternalServerError, "permission check error") diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index b86ebaba..1be1cbd9 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil } + +type HydraIntrospectionResponse struct { + Active bool `json:"active"` + Subject string `json:"sub"` + ClientID string `json:"client_id"` + Scope string `json:"scope"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Ext map[string]interface{} `json:"ext"` +} + +func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) { + endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) + form := url.Values{} + form.Set("token", token) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := s.httpClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return nil, fmt.Errorf("hydra admin: introspection failed status=%d body=%s", resp.StatusCode, string(body)) + } + + var res HydraIntrospectionResponse + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + return nil, err + } + return &res, nil +} diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go index c1c161a5..c3fceff7 100644 --- a/backend/internal/service/tenant_service.go +++ b/backend/internal/service/tenant_service.go @@ -13,7 +13,7 @@ import ( ) type TenantService interface { - RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) + RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) @@ -90,7 +90,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string return s.repo.FindByIDs(ctx, allIDs) } -func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { +func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) { // Validate Slug if ok, msg := utils.ValidateSlug(slug); !ok { return nil, errors.New(msg) @@ -119,15 +119,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy return nil, err } - // [Keto] Sync hierarchy via Outbox if ParentID exists - if s.outboxRepo != nil && tenant.ParentID != nil { - _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ - Namespace: "Tenant", - Object: tenant.ID, - Relation: "parents", - Subject: "Tenant:" + *tenant.ParentID, - Action: domain.KetoOutboxActionCreate, - }) + // [Keto] Sync hierarchy and ownership via Outbox + if s.outboxRepo != nil { + // Sync hierarchy + if tenant.ParentID != nil { + if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "parents", + Subject: "Tenant:" + *tenant.ParentID, + Action: domain.KetoOutboxActionCreate, + }); err != nil { + slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err) + } + } + + // Sync creator ownership + if creatorID != "" { + slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID) + // Add as owner + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "owners", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + // Add as admin + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "admins", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + // Add as member + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "members", + Subject: "User:" + creatorID, + Action: domain.KetoOutboxActionCreate, + }) + } } // 3. Add Domains (Auto-verify for manual admin registration) @@ -187,12 +221,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { // [Keto] Sync relation via Outbox if s.outboxRepo != nil { if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { - slog.Info("Queueing tenant admin sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) + slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) // Check if user already exists in our Read-Model if s.userRepo != nil { user, err := s.userRepo.FindByEmail(ctx, adminEmail) if err == nil && user != nil { - // User exists, assign Admin role in Keto via Outbox + // User exists, assign Admin, Owner, and Member roles in Keto via Outbox + slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID) + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "owners", + Subject: "User:" + user.ID, + Action: domain.KetoOutboxActionCreate, + }) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ Namespace: "Tenant", Object: tenant.ID, @@ -200,6 +242,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error { Subject: "User:" + user.ID, Action: domain.KetoOutboxActionCreate, }) + _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ + Namespace: "Tenant", + Object: tenant.ID, + Relation: "members", + Subject: "User:" + user.ID, + Action: domain.KetoOutboxActionCreate, + }) } else { slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail) } diff --git a/backend/internal/service/tenant_service_edge_test.go b/backend/internal/service/tenant_service_edge_test.go index f446b11a..a2a34002 100644 --- a/backend/internal/service/tenant_service_edge_test.go +++ b/backend/internal/service/tenant_service_edge_test.go @@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) { // Mock: slug already exists mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) - tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil) + tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) assert.Contains(t, err.Error(), "already exists") assert.Nil(t, tenant) @@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) { ctx := context.Background() // Case 1: Too short - _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil) + _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) // Case 2: Invalid characters - _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil) + _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil, "") assert.Error(t, err) } diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go index 2952bfe8..6ca1bc06 100644 --- a/backend/internal/service/tenant_service_test.go +++ b/backend/internal/service/tenant_service_test.go @@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() - tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil) + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil, "") assert.NoError(t, err) assert.NotNil(t, tenant) assert.Equal(t, "t1", tenant.ID) mockRepo.AssertExpectations(t) } +func TestTenantService_RegisterTenant_WithCreator(t *testing.T) { + mockRepo := new(MockTenantRepoForSvc) + mockOutbox := new(MockKetoOutboxRepositoryShared) + svc := NewTenantService(mockRepo, nil, mockOutbox) + + ctx := context.Background() + name := "Creator Tenant" + slug := "creator-tenant" + creatorID := "creator-uuid" + tenantID := "t-new" + + mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once() + mockRepo.On("Create", ctx, mock.MatchedBy(func(t *domain.Tenant) bool { + return t.Slug == slug + })).Run(func(args mock.Arguments) { + t := args.Get(1).(*domain.Tenant) + t.ID = tenantID + }).Return(nil) + + // Expect owners sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+creatorID + })).Return(nil) + // Expect admins sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+creatorID + })).Return(nil) + // Expect members sync + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+creatorID + })).Return(nil) + + mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: tenantID, Slug: slug}, nil).Once() + + tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", nil, nil, creatorID) + assert.NoError(t, err) + assert.NotNil(t, tenant) + mockRepo.AssertExpectations(t) + mockOutbox.AssertExpectations(t) +} + func TestTenantService_RequestRegistration_NoVerify(t *testing.T) { mockRepo := new(MockTenantRepoForSvc) mockOutbox := new(MockKetoOutboxRepositoryShared) @@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) { mockRepo.On("Update", ctx, mock.Anything).Return(nil) mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil) // Now using Outbox instead of direct Keto call + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+userID + })).Return(nil) mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID })).Return(nil) + mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { + return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID + })).Return(nil) err := svc.ApproveTenant(ctx, tenantID) assert.NoError(t, err) diff --git a/docker/ory/keto/namespaces.ts b/docker/ory/keto/namespaces.ts index 06b111bf..eabdc7fc 100644 --- a/docker/ory/keto/namespaces.ts +++ b/docker/ory/keto/namespaces.ts @@ -5,7 +5,7 @@ class User implements Namespace {} class Tenant implements Namespace { related: { owners: User[] - admins: (User | SubjectSet)[] + admins: User[] members: User[] parents: Tenant[] } @@ -14,12 +14,18 @@ class Tenant implements Namespace { view: (ctx: Context): boolean => this.related.members.includes(ctx.subject) || this.related.admins.includes(ctx.subject) || + this.related.owners.includes(ctx.subject) || this.related.parents.traverse((p) => p.permits.view(ctx)), manage: (ctx: Context): boolean => this.related.admins.includes(ctx.subject) || + this.related.owners.includes(ctx.subject) || this.related.parents.traverse((p) => p.permits.manage(ctx)), - + + manage_admins: (ctx: Context): boolean => + this.related.owners.includes(ctx.subject) || + this.related.parents.traverse((p) => p.permits.manage_admins(ctx)), + create_subtenant: (ctx: Context): boolean => this.permits.manage(ctx) }