1
0
forked from baron/baron-sso

adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현

This commit is contained in:
2026-06-10 10:01:30 +09:00
parent c880b3c333
commit 85707500ef
13 changed files with 485 additions and 40 deletions

View File

@@ -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),

View File

@@ -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}
/>

View File

@@ -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>

View File

@@ -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}</>;
}

View File

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

View 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 };
}

View File

@@ -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", "관리자 추가")}

View File

@@ -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 ${

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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;
};

View File

@@ -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)
}

View 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)
}