From 44231b1c2e651726ea5cee929b461ca5c2ad39b6 Mon Sep 17 00:00:00 2001 From: kyy Date: Thu, 19 Mar 2026 13:34:47 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=AD=ED=95=A0=EB=B3=84=20403=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/common/ForbiddenMessage.tsx | 51 +++++++++++++++++ devfront/src/features/audit/AuditLogsPage.tsx | 10 +--- devfront/src/features/clients/ClientsPage.tsx | 8 ++- devfront/tests/devfront-security.spec.ts | 56 +++++++++++++++++++ locales/en.toml | 7 +++ locales/ko.toml | 7 +++ locales/template.toml | 7 +++ 7 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 devfront/src/components/common/ForbiddenMessage.tsx diff --git a/devfront/src/components/common/ForbiddenMessage.tsx b/devfront/src/components/common/ForbiddenMessage.tsx new file mode 100644 index 00000000..9466ce36 --- /dev/null +++ b/devfront/src/components/common/ForbiddenMessage.tsx @@ -0,0 +1,51 @@ +import { ShieldAlert } from "lucide-react"; +import { useAuth } from "react-oidc-context"; +import { t } from "../../lib/i18n"; +import { resolveProfileRole } from "../../lib/role"; + +interface Props { + resourceToken: "audit" | "clients"; +} + +export function ForbiddenMessage({ resourceToken }: Props) { + const auth = useAuth(); + const rawProfile = auth.user?.profile as Record | undefined; + const role = resolveProfileRole(rawProfile); + + let explanation = t( + "msg.dev.forbidden.default", + "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요.", + ); + + if (role === "rp_admin") { + explanation = t( + "msg.dev.forbidden.rp_admin", + "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다.", + ); + } else if (role === "tenant_admin") { + explanation = t( + "msg.dev.forbidden.tenant_admin", + "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다.", + ); + } else if (role === "user" || role === "tenant_member") { + explanation = t( + "msg.dev.forbidden.user", + "일반 사용자는 관리자 화면에 접근할 수 없습니다.", + ); + } + + const title = t("msg.dev.forbidden.title", "{{resource}} 접근 권한 없음", { + resource: + resourceToken === "audit" + ? t("ui.dev.audit.title", "Audit Logs") + : t("ui.dev.clients.registry.subtitle", "연동 앱"), + }); + + return ( +
+ +

{title}

+

{explanation}

+
+ ); +} diff --git a/devfront/src/features/audit/AuditLogsPage.tsx b/devfront/src/features/audit/AuditLogsPage.tsx index d4dcc6e1..1330b979 100644 --- a/devfront/src/features/audit/AuditLogsPage.tsx +++ b/devfront/src/features/audit/AuditLogsPage.tsx @@ -11,6 +11,7 @@ import { import * as React from "react"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; +import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Card, CardContent, @@ -170,14 +171,7 @@ function AuditLogsPage() { if (query.error) { const axiosError = query.error as AxiosError<{ error?: string }>; if (axiosError.response?.status === 403) { - return ( -
- {t( - "msg.dev.audit.forbidden", - "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요.", - )} -
- ); + return ; } const errMsg = diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 5ad69d78..bba994ff 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -18,6 +18,7 @@ import { } from "../../components/ui/avatar"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; +import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; import { Card, CardContent, @@ -133,9 +134,12 @@ function ClientsPage() { } if (clientError) { + const axiosError = clientError as AxiosError<{ error?: string }>; + if (axiosError.response?.status === 403) { + return ; + } const errMsg = - (clientError as AxiosError<{ error?: string }>).response?.data?.error ?? - (clientError as Error).message; + axiosError.response?.data?.error ?? (clientError as Error).message; return (
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", { diff --git a/devfront/tests/devfront-security.spec.ts b/devfront/tests/devfront-security.spec.ts index f6c7cd1a..319cab8f 100644 --- a/devfront/tests/devfront-security.spec.ts +++ b/devfront/tests/devfront-security.spec.ts @@ -57,4 +57,60 @@ test.describe("DevFront security and isolation", () => { ).toBeVisible(); await expect(page).toHaveURL(/\/clients$/); }); + + test("rp_admin receives 403 on clients list and sees ForbiddenMessage", async ({ + page, + }) => { + await seedAuth(page, "rp_admin"); + + const state = { + clients: [] as ReturnType[], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.route("**/api/v1/dev/clients", async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 403, + contentType: "application/json", + body: '{"error": "forbidden"}', + }); + } + return route.fallback(); + }); + + await page.goto("/clients"); + await expect(page.getByText(/RP 관리자는|RP administrators can only access/i)).toBeVisible(); + }); + + test("tenant_admin receives 403 on audit logs and sees ForbiddenMessage", async ({ + page, + }) => { + await seedAuth(page, "tenant_admin"); + + const state = { + clients: [] as ReturnType[], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.route("**/api/v1/dev/audit-logs*", async (route) => { + if (route.request().method() === "GET") { + return route.fulfill({ + status: 403, + contentType: "application/json", + body: '{"error": "forbidden"}', + }); + } + return route.fallback(); + }); + + await page.goto("/audit-logs"); + await expect( + page.getByText(/테넌트 관리자 권한|Tenant administrator permissions/i), + ).toBeVisible(); + }); }); diff --git a/locales/en.toml b/locales/en.toml index 61fd7bc4..1acc7307 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -326,6 +326,13 @@ logout_confirm = "Are you sure you want to log out?" access_denied_description = "DevFront is for administrators only. Request access from your administrator." access_denied_title = "Access denied." +[msg.dev.forbidden] +default = "You do not have permission to access this resource. Please contact an administrator." +rp_admin = "RP administrators can only access resources for the apps they manage." +tenant_admin = "Tenant administrator permissions are not configured correctly or have expired." +user = "Regular users cannot access the developer console." +title = "Access Denied: {{resource}}" + [msg.dev.audit] empty = "No audit logs found." forbidden = "You do not have permission to view audit logs. Please request access from an administrator." diff --git a/locales/ko.toml b/locales/ko.toml index aec1a837..56552cb1 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -326,6 +326,13 @@ logout_confirm = "로그아웃 하시겠습니까?" access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요." access_denied_title = "접근 권한이 없습니다." +[msg.dev.forbidden] +default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요." +rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다." +tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다." +user = "일반 사용자는 관리자 화면에 접근할 수 없습니다." +title = "{{resource}} 접근 권한 없음" + [msg.dev.audit] empty = "조회된 감사 로그가 없습니다." forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요." diff --git a/locales/template.toml b/locales/template.toml index db7e269b..10013546 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -326,6 +326,13 @@ logout_confirm = "" access_denied_description = "" access_denied_title = "" +[msg.dev.forbidden] +default = "" +rp_admin = "" +tenant_admin = "" +user = "" +title = "" + [msg.dev.audit] empty = "" forbidden = ""