From f545aeafacc9f5cbf956b6d1e5727047261b59fc Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 7 Apr 2026 10:33:29 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B6=8C=ED=95=9C=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=20UX=20=EA=B0=9C=EC=84=A0=20+=20=EB=9D=BC=EB=B2=A8=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=EB=B0=94=20=EC=9D=BC=EC=B9=98=20+=20EXPORT?= =?UTF-8?q?=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PermissionsPanel UI 수정: - 같은 노드의 effective READ가 거부되면 C/U/D/E도 forced-denied (READ가 안 되면 그 페이지 자체에 접근 못 하므로 다른 작업도 의미 없음) → 사용자가 Read를 N으로 바꾸는 즉시 같은 행의 CUDE도 회색 비활성 DataTable EXPORT 권한 가드: - exportResource prop 추가 - useAuth().hasPermission(resource, 'EXPORT')로 export 버튼 표시 여부 결정 - AccessControl의 사용자 관리 / 감사 로그 DataTable에 적용 - exportResource="admin:user-management" - exportResource="admin:audit-logs" Operation 의미 명확화: - ParentExclusion release 엔드포인트를 UPDATE → DELETE 로 재분류 (제외 항목을 "삭제(해제)"하는 의미가 더 정확) V007 마이그레이션: 권한 트리 명칭을 사이드바 i18n 라벨과 일치 - Level 0 13개 + Level 1 32개 노드의 rsrc_nm을 nav.* / group.* 라벨에 맞춤 - 예: "어구탐지" → "어구 탐지", "Dark Vessel" → "다크베셀 탐지" - 권한 관리 트리를 운영자가 사이드바와 동일한 명칭으로 이해 가능 API의 RCUDE 적용 현황 (참고): - READ 19건, UPDATE 8건, CREATE 4건, DELETE 1→2건 - EXPORT는 백엔드 엔드포인트 별도 없음 → 프론트 EXPORT 가드로 처리 - 향후 백엔드 CSV/Excel 생성 API 추가 시 EXPORT operation으로 가드 검증: - V007 마이그레이션 자동 적용 + Started in 3.272s - Level 0 13개 모두 사이드바 라벨로 변경됨 확인 - 프론트 빌드 통과 (599ms) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ParentInferenceWorkflowController.java | 2 +- .../migration/V007__perm_tree_label_align.sql | 80 +++++++++++++++++++ frontend/src/features/admin/AccessControl.tsx | 2 + .../src/features/admin/PermissionsPanel.tsx | 12 ++- .../shared/components/common/DataTable.tsx | 10 ++- 5 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V007__perm_tree_label_align.sql diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java index 1da357a..834abc4 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java @@ -64,7 +64,7 @@ public class ParentInferenceWorkflowController { } @PostMapping("/exclusions/{exclusionId}/release") - @RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "UPDATE") + @RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "DELETE") public CandidateExclusion releaseExclusion( @PathVariable Long exclusionId, @RequestBody(required = false) CancelRequest req diff --git a/backend/src/main/resources/db/migration/V007__perm_tree_label_align.sql b/backend/src/main/resources/db/migration/V007__perm_tree_label_align.sql new file mode 100644 index 0000000..d9ccc92 --- /dev/null +++ b/backend/src/main/resources/db/migration/V007__perm_tree_label_align.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- V007: 권한 트리 노드의 한국어 명칭을 좌측 사이드바 i18n 라벨과 일치시킴 +-- (구조는 유지, rsrc_nm만 갱신) +-- ============================================================================ + +-- ─── Level 0 (탭/그룹) ───────────────────────── +UPDATE kcg.auth_perm_tree SET rsrc_nm = '종합 상황판' WHERE rsrc_cd = 'dashboard'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '경보 현황판' WHERE rsrc_cd = 'monitoring'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '감시' WHERE rsrc_cd = 'surveillance'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '탐지·분석' WHERE rsrc_cd = 'detection'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '선박' WHERE rsrc_cd = 'vessel'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험평가' WHERE rsrc_cd = 'risk-assessment'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '순찰·경로' WHERE rsrc_cd = 'patrol'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속·이력' WHERE rsrc_cd = 'enforcement'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '현장 대응' WHERE rsrc_cd = 'field-ops'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 운영' WHERE rsrc_cd = 'ai-operations'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '통계·보고' WHERE rsrc_cd = 'statistics'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '모선 워크플로우' WHERE rsrc_cd = 'parent-inference-workflow'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '시스템 관리' WHERE rsrc_cd = 'admin'; + +-- ─── Level 1 (서브탭/패널) ───────────────────── +-- monitoring 자식 (사이드바엔 없으나 향후 분리 가능성) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '알림 목록' WHERE rsrc_cd = 'monitoring:alert-list'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'KPI 패널' WHERE rsrc_cd = 'monitoring:kpi-panel'; + +-- surveillance 자식 (좌측: /events = live-map, /map-control) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '이벤트 목록' WHERE rsrc_cd = 'surveillance:live-map'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험도 지도' WHERE rsrc_cd = 'surveillance:map-control'; + +-- detection 자식 (사이드바: /dark-vessel, /gear-detection, /china-fishing) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '어구 탐지' WHERE rsrc_cd = 'detection:gear-detection'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '다크베셀 탐지' WHERE rsrc_cd = 'detection:dark-vessel'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '중국어선 분석' WHERE rsrc_cd = 'detection:china-fishing'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '어구식별' WHERE rsrc_cd = 'detection:gear-identification'; + +-- vessel 자식 (사이드바: /vessel/:id 단일) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '선박 상세' WHERE rsrc_cd = 'vessel:vessel-detail'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '환적·접촉 탐지' WHERE rsrc_cd = 'vessel:transfer-detection'; + +-- risk-assessment 자식 (사이드바: /risk-map, /enforcement-plan) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '위험도 지도' WHERE rsrc_cd = 'risk-assessment:risk-map'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속 계획' WHERE rsrc_cd = 'risk-assessment:enforcement-plan'; + +-- patrol 자식 (사이드바: /patrol-route, /fleet-optimization) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '순찰경로 추천' WHERE rsrc_cd = 'patrol:patrol-route'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '다함정 최적화' WHERE rsrc_cd = 'patrol:fleet-optimization'; + +-- enforcement 자식 (사이드바: /enforcement-history, /event-list) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '단속 이력' WHERE rsrc_cd = 'enforcement:enforcement-history'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '이벤트 목록' WHERE rsrc_cd = 'enforcement:event-list'; + +-- field-ops 자식 (사이드바: /mobile-service, /ship-agent, /ai-alert) +UPDATE kcg.auth_perm_tree SET rsrc_nm = '모바일 서비스' WHERE rsrc_cd = 'field-ops:mobile-service'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '함정 Agent' WHERE rsrc_cd = 'field-ops:ship-agent'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 알림 발송' WHERE rsrc_cd = 'field-ops:ai-alert'; + +-- ai-operations 자식 +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 의사결정 지원' WHERE rsrc_cd = 'ai-operations:ai-assistant'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'AI 모델관리' WHERE rsrc_cd = 'ai-operations:ai-model'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = 'MLOps' WHERE rsrc_cd = 'ai-operations:mlops'; + +-- statistics 자식 +UPDATE kcg.auth_perm_tree SET rsrc_nm = '통계 분석' WHERE rsrc_cd = 'statistics:statistics'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '외부 서비스' WHERE rsrc_cd = 'statistics:external-service'; + +-- parent-inference-workflow 자식 +UPDATE kcg.auth_perm_tree SET rsrc_nm = '모선 확정/거부' WHERE rsrc_cd = 'parent-inference-workflow:parent-review'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '후보 제외' WHERE rsrc_cd = 'parent-inference-workflow:parent-exclusion'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '학습 세션' WHERE rsrc_cd = 'parent-inference-workflow:label-session'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '전역 제외 관리' WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management'; + +-- admin 자식 +UPDATE kcg.auth_perm_tree SET rsrc_nm = '사용자 관리' WHERE rsrc_cd = 'admin:user-management'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '역할 관리' WHERE rsrc_cd = 'admin:role-management'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '권한 관리' WHERE rsrc_cd = 'admin:permission-management'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '메뉴 설정' WHERE rsrc_cd = 'admin:menu-management'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '감사 로그' WHERE rsrc_cd = 'admin:audit-logs'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '접근 이력' WHERE rsrc_cd = 'admin:access-logs'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '로그인 이력' WHERE rsrc_cd = 'admin:login-history'; +UPDATE kcg.auth_perm_tree SET rsrc_nm = '환경설정' WHERE rsrc_cd = 'admin:system-config'; diff --git a/frontend/src/features/admin/AccessControl.tsx b/frontend/src/features/admin/AccessControl.tsx index d41b908..0f486a0 100644 --- a/frontend/src/features/admin/AccessControl.tsx +++ b/frontend/src/features/admin/AccessControl.tsx @@ -276,6 +276,7 @@ export function AccessControl() { searchPlaceholder="계정, 이름, 이메일 검색..." searchKeys={['userAcnt', 'userNm', 'email', 'rnkpNm']} exportFilename="사용자목록" + exportResource="admin:user-management" showPagination /> )} @@ -320,6 +321,7 @@ export function AccessControl() { searchPlaceholder="사용자, 액션, IP 검색..." searchKeys={['userAcnt', 'actionCd', 'resourceType', 'ipAddress']} exportFilename="감사로그" + exportResource="admin:audit-logs" title="모든 운영자 의사결정 자동 기록 (audit_log)" showPagination /> diff --git a/frontend/src/features/admin/PermissionsPanel.tsx b/frontend/src/features/admin/PermissionsPanel.tsx index 58b0dbd..8c5947b 100644 --- a/frontend/src/features/admin/PermissionsPanel.tsx +++ b/frontend/src/features/admin/PermissionsPanel.tsx @@ -134,15 +134,21 @@ export function PermissionsPanel() { const key = makeKey(rsrcCd, operCd); const explicit = draftPerms.get(key); - // 부모의 effective READ 확인 + // 1) 부모 노드의 effective READ가 거부되면 자식의 모든 작업 강제 거부 let parentReadDenied = false; if (parentCd) { const parentEff = effective.get(parentCd); parentReadDenied = !parentEff || !parentEff.has('READ'); } + if (parentReadDenied) return 'forced-denied'; - if (parentReadDenied && operCd !== 'READ') return 'forced-denied'; - if (parentReadDenied && operCd === 'READ' && parentCd) return 'forced-denied'; + // 2) 같은 노드의 READ가 effective로 거부되면 C/U/D/E도 강제 거부 + // (READ가 안 되면 그 페이지/리소스 자체에 접근 못 하므로 다른 작업 권한도 의미 없음) + if (operCd !== 'READ') { + const ownEff = effective.get(rsrcCd); + const ownReadGranted = ownEff?.has('READ') ?? false; + if (!ownReadGranted) return 'forced-denied'; + } if (explicit === 'Y') return 'explicit-granted'; if (explicit === 'N') return 'explicit-denied'; diff --git a/frontend/src/shared/components/common/DataTable.tsx b/frontend/src/shared/components/common/DataTable.tsx index 84fe7bc..5653778 100644 --- a/frontend/src/shared/components/common/DataTable.tsx +++ b/frontend/src/shared/components/common/DataTable.tsx @@ -4,6 +4,7 @@ import { Pagination } from './Pagination'; import { ExcelExport } from './ExcelExport'; import { PrintButton } from './PrintButton'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { useAuth } from '@/app/auth/AuthContext'; /* * SFR-02 공통컴포넌트: 데이터 테이블 @@ -38,6 +39,8 @@ interface DataTableProps> { showPagination?: boolean; onRowClick?: (row: T) => void; className?: string; + /** 이 표가 속한 리소스 코드 (예: "admin:audit-logs"). EXPORT 권한 가드에 사용. */ + exportResource?: string; } export function DataTable>({ @@ -54,7 +57,12 @@ export function DataTable>({ showPagination = true, onRowClick, className = '', + exportResource, }: DataTableProps) { + // EXPORT 권한 체크: exportResource가 지정되면 hasPermission으로 가드 + // 미지정 시 항상 표시 (하위 호환) + const { hasPermission } = useAuth(); + const canExport = exportResource ? hasPermission(exportResource, 'EXPORT') : true; const [query, setQuery] = useState(''); const [page, setPage] = useState(0); const [sortKey, setSortKey] = useState(null); @@ -118,7 +126,7 @@ export function DataTable>({ /> )}
- {showExport && ( + {showExport && canExport && ( []} columns={columns.map((c) => ({ key: c.key, label: c.label }))}