forked from baron/baron-sso
adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현
This commit is contained in:
@@ -93,6 +93,13 @@ vi.mock("react-oidc-context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(async () => users[0]),
|
||||
fetchTenant: vi.fn(async (tenantId) => ({
|
||||
id: tenantId,
|
||||
name: "Test Tenant",
|
||||
slug: "test-tenant",
|
||||
userPermissions: { view: true, manage: true, manage_admins: true },
|
||||
})),
|
||||
fetchTenantOwners: vi.fn(async () => [users[0]]),
|
||||
fetchTenantAdmins: vi.fn(async () => [users[1]]),
|
||||
addTenantOwner: vi.fn(async () => undefined),
|
||||
|
||||
@@ -29,6 +29,7 @@ type DomainTagInputProps = {
|
||||
confirmedConflicts?: string[];
|
||||
onConfirmedConflictsChange?: (domains: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function DomainTagInput({
|
||||
@@ -40,6 +41,7 @@ export function DomainTagInput({
|
||||
confirmedConflicts = [],
|
||||
onConfirmedConflictsChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: DomainTagInputProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
|
||||
@@ -107,14 +109,16 @@ export function DomainTagInput({
|
||||
className="gap-1 rounded-md"
|
||||
>
|
||||
<span>{domain}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
|
||||
onClick={() => removeDomain(domain)}
|
||||
aria-label={t("ui.common.remove", "삭제")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
@@ -133,6 +137,7 @@ export function DomainTagInput({
|
||||
tokenizeInput();
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,7 @@ type ParentTenantSelectorProps = {
|
||||
localTenantFilter?: (tenant: TenantSummary) => boolean;
|
||||
compact?: boolean;
|
||||
controlTestId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ParentTenantSelector({
|
||||
@@ -53,6 +54,7 @@ export function ParentTenantSelector({
|
||||
localTenantFilter,
|
||||
compact = false,
|
||||
controlTestId,
|
||||
disabled = false,
|
||||
}: ParentTenantSelectorProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [localPickerOpen, setLocalPickerOpen] = useState(false);
|
||||
@@ -112,6 +114,7 @@ export function ParentTenantSelector({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={compact ? "h-8 shrink-0 px-2" : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{orgChartPickerLabel ??
|
||||
@@ -141,7 +144,7 @@ export function ParentTenantSelector({
|
||||
{localPickerLabel && (
|
||||
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" size="sm">
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled}>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{localPickerLabel}
|
||||
</Button>
|
||||
@@ -228,6 +231,7 @@ export function ParentTenantSelector({
|
||||
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
|
||||
onClick={() => onChange("")}
|
||||
aria-label={noneLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type React from "react";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
interface TenantPermissionGuardProps {
|
||||
tenantId: string;
|
||||
relation: "view" | "manage" | "manage_admins";
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TenantPermissionGuard({
|
||||
tenantId,
|
||||
relation,
|
||||
fallback = null,
|
||||
children,
|
||||
}: TenantPermissionGuardProps) {
|
||||
const { hasPermission, isLoading } = useTenantPermission(tenantId);
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (!hasPermission(relation)) {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
||||
import { useTenantPermission } from "./useTenantPermission";
|
||||
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
fetchMe: vi.fn(),
|
||||
fetchTenant: vi.fn(),
|
||||
}));
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useTenantPermission", () => {
|
||||
it("returns true for all permissions if user is super_admin", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-super",
|
||||
role: "super_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-1",
|
||||
name: "Super Tenant",
|
||||
userPermissions: { view: false, manage: false, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-2",
|
||||
name: "Tenant Admin Corp",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.hasPermission("view")).toBe(true);
|
||||
expect(result.current.hasPermission("manage")).toBe(true);
|
||||
expect(result.current.hasPermission("manage_admins")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TenantPermissionGuard", () => {
|
||||
it("renders children when user has permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-3",
|
||||
userPermissions: { view: true, manage: true, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard tenantId="tenant-3" relation="manage" fallback={<div>Access Denied</div>}>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Granted")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders fallback when user lacks permission", async () => {
|
||||
vi.mocked(fetchMe).mockResolvedValue({
|
||||
id: "user-admin",
|
||||
role: "tenant_admin",
|
||||
} as any);
|
||||
|
||||
vi.mocked(fetchTenant).mockResolvedValue({
|
||||
id: "tenant-4",
|
||||
userPermissions: { view: true, manage: false, manage_admins: false },
|
||||
} as any);
|
||||
|
||||
render(
|
||||
<TenantPermissionGuard tenantId="tenant-4" relation="manage" fallback={<div>Access Denied</div>}>
|
||||
<div>Access Granted</div>
|
||||
</TenantPermissionGuard>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Access Denied")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
26
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
26
adminfront/src/features/tenants/hooks/useTenantPermission.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchTenant, fetchMe } from "../../../lib/adminApi";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
|
||||
export function useTenantPermission(tenantId: string) {
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const { data: tenant } = useQuery({
|
||||
queryKey: ["tenant", tenantId],
|
||||
queryFn: () => fetchTenant(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
const hasPermission = (requiredRelation: "view" | "manage" | "manage_admins"): boolean => {
|
||||
// Super Admin always has full bypass access
|
||||
if (normalizeAdminRole(profile?.role) === "super_admin") {
|
||||
return true;
|
||||
}
|
||||
return !!tenant?.userPermissions?.[requiredRelation];
|
||||
};
|
||||
|
||||
return { hasPermission, isLoading: !tenant };
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -69,6 +70,8 @@ export function TenantAdminsAndOwnersTab() {
|
||||
const _currentUserId = auth.user?.profile.sub;
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage_admins");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||
@@ -382,6 +385,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
@@ -471,6 +475,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "../../../components/ui/button";
|
||||
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
|
||||
function TenantDetailPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
@@ -17,13 +18,7 @@ function TenantDetailPage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
});
|
||||
|
||||
const profileRole = normalizeAdminRole(profile?.role);
|
||||
const canAccessSchema = profileRole === "super_admin";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
@@ -110,7 +105,7 @@ function TenantDetailPage() {
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
|
||||
</Link>
|
||||
{canAccessSchema && (
|
||||
{hasPermission("view") && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/schema`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
|
||||
AxiosError<{ error?: string }>,
|
||||
{ groupId: string; userId: string }
|
||||
>;
|
||||
isWritable?: boolean;
|
||||
}
|
||||
|
||||
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
onAddSubGroup,
|
||||
addMemberMutation,
|
||||
removeMemberMutation,
|
||||
isWritable = true,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
e.stopPropagation();
|
||||
onAddSubGroup(node.id);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
e.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
isWritable={isWritable}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -240,6 +246,9 @@ function TenantGroupsPage() {
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const _queryClient = useQueryClient();
|
||||
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage");
|
||||
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
||||
@@ -423,6 +432,7 @@ function TenantGroupsPage() {
|
||||
id="name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.name_placeholder",
|
||||
"예: 개발팀, 인사팀",
|
||||
@@ -437,6 +447,7 @@ function TenantGroupsPage() {
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
@@ -449,9 +460,10 @@ function TenantGroupsPage() {
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={newGroupParentId || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
@@ -469,6 +481,7 @@ function TenantGroupsPage() {
|
||||
id="desc"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.desc_placeholder",
|
||||
"그룹 용도 설명",
|
||||
@@ -478,7 +491,7 @@ function TenantGroupsPage() {
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!newGroupName || createMutation.isPending}
|
||||
disabled={!newGroupName || createMutation.isPending || !isWritable}
|
||||
>
|
||||
{t("ui.admin.groups.form.submit", "생성하기")}
|
||||
</Button>
|
||||
@@ -569,6 +582,7 @@ function TenantGroupsPage() {
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
isWritable={isWritable}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
import { useTenantPermission } from "../hooks/useTenantPermission";
|
||||
import {
|
||||
formatDomainConflictMessage,
|
||||
type ServerDomainConflict,
|
||||
@@ -52,6 +53,9 @@ export function TenantProfilePage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
@@ -261,13 +265,13 @@ export function TenantProfilePage() {
|
||||
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} disabled={!isWritable} />
|
||||
</div>
|
||||
<div data-testid="tenant-slug-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
|
||||
</Label>
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<Input value={slug} onChange={(e) => setSlug(e.target.value)} disabled={!isWritable} />
|
||||
</div>
|
||||
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
|
||||
<ParentTenantSelector
|
||||
@@ -283,6 +287,7 @@ export function TenantProfilePage() {
|
||||
excludeTenantId={tenantId}
|
||||
compact
|
||||
controlTestId="tenant-parent-picker-control"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,6 +305,7 @@ export function TenantProfilePage() {
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="COMPANY">
|
||||
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
|
||||
@@ -346,9 +352,10 @@ export function TenantProfilePage() {
|
||||
id="tenant-org-unit-type"
|
||||
name="tenant-org-unit-type"
|
||||
data-testid="tenant-org-unit-type-select"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={orgUnitType}
|
||||
onChange={(event) => setOrgUnitType(event.target.value)}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{orgUnitTypeOptions.map((option) => (
|
||||
@@ -365,13 +372,14 @@ export function TenantProfilePage() {
|
||||
<select
|
||||
id="tenant-visibility"
|
||||
name="tenant-visibility"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={tenantVisibility}
|
||||
onChange={(event) =>
|
||||
setTenantVisibility(
|
||||
event.target.value as TenantVisibility,
|
||||
)
|
||||
}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
@@ -392,11 +400,12 @@ export function TenantProfilePage() {
|
||||
</Label>
|
||||
<select
|
||||
id="worksmobileExcluded"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={worksmobileExcluded ? "excluded" : "enabled"}
|
||||
onChange={(event) =>
|
||||
setWorksmobileExcluded(event.target.value === "excluded")
|
||||
}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
<option value="enabled">
|
||||
{t(
|
||||
@@ -424,6 +433,7 @@ export function TenantProfilePage() {
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -442,6 +452,7 @@ export function TenantProfilePage() {
|
||||
confirmedConflicts={forceDomainConflicts}
|
||||
onConfirmedConflictsChange={setForceDomainConflicts}
|
||||
placeholder="example.com, example.kr"
|
||||
disabled={!isWritable}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -454,6 +465,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "active" ? "default" : "outline"}
|
||||
onClick={() => setStatus("active")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.active", "활성")}
|
||||
</Button>
|
||||
@@ -462,6 +474,7 @@ export function TenantProfilePage() {
|
||||
size="sm"
|
||||
variant={status === "inactive" ? "default" : "outline"}
|
||||
onClick={() => setStatus("inactive")}
|
||||
disabled={!isWritable}
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성")}
|
||||
</Button>
|
||||
@@ -480,7 +493,7 @@ export function TenantProfilePage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteMutation.isPending || isProtectedSeedTenant}
|
||||
disabled={deleteMutation.isPending || isProtectedSeedTenant || !isWritable}
|
||||
title={
|
||||
isProtectedSeedTenant
|
||||
? t(
|
||||
@@ -499,7 +512,7 @@ export function TenantProfilePage() {
|
||||
variant="default"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleApprove}
|
||||
disabled={approveMutation.isPending}
|
||||
disabled={approveMutation.isPending || !isWritable}
|
||||
>
|
||||
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
|
||||
</Button>
|
||||
@@ -512,7 +525,8 @@ export function TenantProfilePage() {
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
tenantQuery.isLoading ||
|
||||
name.trim() === ""
|
||||
name.trim() === "" ||
|
||||
!isWritable
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
|
||||
@@ -33,6 +33,11 @@ export type TenantSummary = {
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // 해당 테넌트 직접 소속 인원
|
||||
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
|
||||
userPermissions?: {
|
||||
view: boolean;
|
||||
manage: boolean;
|
||||
manage_admins: boolean;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -84,20 +84,27 @@ func (h *TenantHandler) SetWorksmobileSyncer(syncer service.WorksmobileSyncer) {
|
||||
h.Worksmobile = syncer
|
||||
}
|
||||
|
||||
type tenantPermissions struct {
|
||||
View bool `json:"view"`
|
||||
Manage bool `json:"manage"`
|
||||
ManageAdmins bool `json:"manage_admins"`
|
||||
}
|
||||
|
||||
type tenantSummary struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
TotalMemberCount int64 `json:"totalMemberCount"`
|
||||
UserPermissions *tenantPermissions `json:"userPermissions,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type tenantListResponse struct {
|
||||
@@ -1678,6 +1685,53 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
summary.MemberCount = memberCounts[tenant.ID]
|
||||
summary.TotalMemberCount = totalMemberCounts[tenant.ID]
|
||||
|
||||
// Populate Keto-based permissions for the current user
|
||||
profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if ok && profile != nil {
|
||||
role := domain.NormalizeRole(profile.Role)
|
||||
if role == domain.RoleSuperAdmin {
|
||||
summary.UserPermissions = &tenantPermissions{
|
||||
View: true,
|
||||
Manage: true,
|
||||
ManageAdmins: true,
|
||||
}
|
||||
} else {
|
||||
// Query Keto in parallel for maximum performance
|
||||
subject := "User:" + profile.ID
|
||||
type checkResult struct {
|
||||
relation string
|
||||
allowed bool
|
||||
err error
|
||||
}
|
||||
ch := make(chan checkResult, 3)
|
||||
relations := []string{"view", "manage", "manage_admins"}
|
||||
for _, rel := range relations {
|
||||
go func(r string) {
|
||||
allowed, err := h.Keto.CheckPermission(c.Context(), subject, "Tenant", tenant.ID, r)
|
||||
ch <- checkResult{relation: r, allowed: allowed, err: err}
|
||||
}(rel)
|
||||
}
|
||||
|
||||
perms := &tenantPermissions{}
|
||||
for range relations {
|
||||
res := <-ch
|
||||
if res.err != nil {
|
||||
slog.Error("Failed to check Keto permission in GetTenant", "error", res.err, "relation", res.relation, "userID", profile.ID, "tenantID", tenant.ID)
|
||||
continue
|
||||
}
|
||||
switch res.relation {
|
||||
case "view":
|
||||
perms.View = res.allowed
|
||||
case "manage":
|
||||
perms.Manage = res.allowed
|
||||
case "manage_admins":
|
||||
perms.ManageAdmins = res.allowed
|
||||
}
|
||||
}
|
||||
summary.UserPermissions = perms
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(summary)
|
||||
}
|
||||
|
||||
|
||||
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
164
backend/internal/handler/tenant_handler_get_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/testsupport"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestTenantHandler_GetTenant_SuperAdmin(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||
}
|
||||
|
||||
// Create a test tenant in DB with a valid UUID
|
||||
tenant := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000010",
|
||||
Name: "Super Admin Test Tenant",
|
||||
Slug: "super-admin-test-tenant",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
// Mock projection status
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000010": 5}, nil)
|
||||
|
||||
h := &TenantHandler{
|
||||
DB: db,
|
||||
Service: mockSvc,
|
||||
Keto: mockKeto,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
// We'll simulate middleware setting "user_profile" for a Super Admin
|
||||
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-super-admin-id",
|
||||
Role: domain.RoleSuperAdmin,
|
||||
}
|
||||
c.Locals("user_profile", profile)
|
||||
return h.GetTenant(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000010", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got tenantSummary
|
||||
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "00000000-0000-0000-0000-000000000010", got.ID)
|
||||
assert.Equal(t, "Super Admin Test Tenant", got.Name)
|
||||
assert.NotNil(t, got.UserPermissions)
|
||||
assert.True(t, got.UserPermissions.View)
|
||||
assert.True(t, got.UserPermissions.Manage)
|
||||
assert.True(t, got.UserPermissions.ManageAdmins)
|
||||
}
|
||||
|
||||
func TestTenantHandler_GetTenant_NormalUser(t *testing.T) {
|
||||
if !testsupport.DockerAvailable() {
|
||||
t.Skip("Docker provider is unavailable in this environment")
|
||||
}
|
||||
|
||||
db := newTenantHandlerSeedDeleteDB(t)
|
||||
if err := db.AutoMigrate(&domain.TenantDomain{}); err != nil {
|
||||
t.Fatalf("failed to migrate tenant domains: %v", err)
|
||||
}
|
||||
|
||||
// Create a test tenant in DB with a valid UUID
|
||||
tenant := domain.Tenant{
|
||||
ID: "00000000-0000-0000-0000-000000000020",
|
||||
Name: "Normal User Test Tenant",
|
||||
Slug: "normal-user-test-tenant",
|
||||
Type: domain.TenantTypeCompany,
|
||||
Status: domain.TenantStatusActive,
|
||||
}
|
||||
if err := db.Create(&tenant).Error; err != nil {
|
||||
t.Fatalf("failed to create tenant: %v", err)
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockKeto := new(devMockKetoService)
|
||||
mockProjection := new(MockUserProjectionRepoForHandler)
|
||||
|
||||
// Mock projection status
|
||||
mockProjection.On("IsReady", mock.Anything).Return(true, nil)
|
||||
mockProjection.On("CountTenantMembers", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||
mockProjection.On("CountTenantMembersRecursive", mock.Anything, mock.Anything).Return(map[string]int64{"00000000-0000-0000-0000-000000000020": 2}, nil)
|
||||
|
||||
h := &TenantHandler{
|
||||
DB: db,
|
||||
Service: mockSvc,
|
||||
Keto: mockKeto,
|
||||
UserProjectionRepo: mockProjection,
|
||||
}
|
||||
|
||||
// Mock Keto response: allowed view/manage but not manage_admins
|
||||
subject := "User:user-normal-id"
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "view").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, subject, "Tenant", "00000000-0000-0000-0000-000000000020", "manage_admins").Return(false, nil)
|
||||
|
||||
// We'll simulate middleware setting "user_profile" for a regular admin/user
|
||||
app.Get("/tenants/:id", func(c *fiber.Ctx) error {
|
||||
profile := &domain.UserProfileResponse{
|
||||
ID: "user-normal-id",
|
||||
Role: domain.RoleUser,
|
||||
}
|
||||
c.Locals("user_profile", profile)
|
||||
return h.GetTenant(c)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants/00000000-0000-0000-0000-000000000020", nil)
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got tenantSummary
|
||||
err = json.NewDecoder(resp.Body).Decode(&got)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "00000000-0000-0000-0000-000000000020", got.ID)
|
||||
assert.Equal(t, "Normal User Test Tenant", got.Name)
|
||||
assert.NotNil(t, got.UserPermissions)
|
||||
assert.True(t, got.UserPermissions.View)
|
||||
assert.True(t, got.UserPermissions.Manage)
|
||||
assert.False(t, got.UserPermissions.ManageAdmins)
|
||||
|
||||
mockKeto.AssertExpectations(t)
|
||||
}
|
||||
Reference in New Issue
Block a user