diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 54bb5eba..ff67a0a7 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -695,13 +695,21 @@ func (h *DevHandler) SearchUsers(c *fiber.Ctx) error { if profile == nil { return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required") } - if !isDevConsoleRoleAllowed(normalizeUserRole(profile.Role)) { + + // Tightened Security: Only SuperAdmin bypasses the client-specific manage check. + // Regular users (RoleUser) or RPAdmins must have the 'manage' permit for the requested clientId. + if normalizeUserRole(profile.Role) != domain.RoleSuperAdmin { clientID := strings.TrimSpace(c.Query("clientId")) + if clientID == "" { + return errorJSON(c, fiber.StatusBadRequest, "clientId is required for user search") + } summary, err := h.loadClientSummary(c.Context(), clientID) - if clientID == "" || err != nil || !h.canManageClientRelations(c, profile, summary) { - return errorJSON(c, fiber.StatusForbidden, "forbidden") + if err != nil || !h.canManageClientRelations(c, profile, summary) { + // canManageClientRelations checks for 'manage' permit in Keto. + return errorJSON(c, fiber.StatusForbidden, "forbidden: manage permission required for user search") } } + if h.KratosAdmin == nil { return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable") } diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index e754f540..64967498 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -32,6 +32,8 @@ import { removeClientRelation, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; +import { useAuth } from "react-oidc-context"; import { ClientDetailTabs } from "./ClientDetailTabs"; const relationOptions = [ @@ -68,6 +70,7 @@ function formatUserLabel(user: DevAssignableUser) { function ClientRelationsPage() { const params = useParams(); + const auth = useAuth(); const queryClient = useQueryClient(); const clientId = params.id ?? ""; const [selectedRelations, setSelectedRelations] = useState( @@ -79,6 +82,11 @@ function ClientRelationsPage() { null, ); const [isSearchOpen, setIsSearchOpen] = useState(false); + + const systemRole = resolveProfileRole( + auth.user?.profile as Record | undefined, + ); + const { data: clientData } = useQuery({ queryKey: ["client", clientId], queryFn: () => fetchClient(clientId), @@ -95,6 +103,19 @@ function ClientRelationsPage() { enabled: clientId.length > 0, }); + // Calculate permissions for UI hints and button states + const isSuperAdmin = systemRole === "super_admin"; + const myUserId = auth.user?.profile.sub; + const isRpAdmin = useMemo(() => { + if (isSuperAdmin) return true; + if (!relationData?.items || !myUserId) return false; + return relationData.items.some( + (item) => item.subject === `User:${myUserId}` && item.relation === "admins" + ); + }, [relationData?.items, myUserId, isSuperAdmin]); + + const canManageRelations = isRpAdmin || isSuperAdmin; + const isRelationshipViewForbidden = (error as AxiosError | null)?.response?.status === 403; const relationshipViewForbiddenMessage = t( @@ -221,6 +242,16 @@ function ClientRelationsPage() { }); const handleAdd = () => { + if (!canManageRelations) { + toast( + t( + "msg.dev.clients.relationships.add_forbidden_viewer", + "'관계 조회' 권한만으로는 새로운 관계를 추가하거나 사용자를 검색할 수 없습니다. 'RP 관리자' 권한이 필요합니다.", + ), + "error", + ); + return; + } if (!selectedUser) { toast( t( @@ -398,18 +429,26 @@ function ClientRelationsPage() { )} ) : isUserSearchForbidden ? ( -
- {t( - "msg.dev.clients.relationships.search_forbidden_user", - "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", - )} +
+

+ {t( + "msg.dev.clients.relationships.search_forbidden_user", + "일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.", + )} +

+

+ {t( + "msg.dev.clients.relationships.search_forbidden_user_hint", + "'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.", + )} +

) : (userSearchData?.items ?? []).length > 0 ? ( (userSearchData?.items ?? []).map((user) => ( )) ) : ( -
+
{t( "msg.dev.clients.relationships.search_empty", "검색 결과가 없습니다.", @@ -503,7 +542,7 @@ function ClientRelationsPage() {