Files
sam-react-prod/claudedocs/security/[PLAN-2025-01-20] permission-system-implementation.md
유병철 f3b07ac875 chore(WEB): claudedocs 디렉토리 도메인별 재구조화
- 루트 문서 30개를 도메인별 하위 폴더로 이동
- accounting/, architecture/, dev/, guides/, security/ 등 카테고리 분류
- archive/ 폴더에 QA 스크린샷 이동
- _index.md 문서 맵 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:35:22 +09:00

11 KiB

권한 시스템 구현 문서

작성일: 2025-01-20 최종 수정: 2026-02-02 상태: 구현 완료 + 1차 검수 완료 — 세부 검수 및 사이드바 필터링 미진행


1. 핵심 요약

구현 방식: URL 자동매칭 + Role 기반 API

항목 내용
API GET /api/v1/roles/{roleId}/permissions/matrix (설정 페이지와 동일)
데이터 소스 localStorage user.roles → roleId, user.menu → menuId↔URL 매핑
매칭 전략 Longest prefix match (locale 자동 제거)
다중 역할 Union 병합 (하나라도 허용이면 허용)
Fallback fail-open (API 실패, 미등록 URL → 전체 허용)
즉시 반영 설정 페이지에서 변경 시 reloadPermissions() 호출
자기 잠금 방지 /settings/permissions는 항상 접근 허용

2. 아키텍처

2.1 보호 계층 구조

AuthenticatedLayout          ← 1차: 로그인 체크
  └── PermissionProvider     ← 권한 데이터 로드 (Role API)
       └── PermissionGate    ← 2차: 페이지 접근 권한 (view 자동 체크)
            └── page.tsx     ← 3차: 액션 권한 (버튼 숨김)

2.2 데이터 흐름

┌─────────────────────────────────────────────────────────────┐
│  마운트 시 (또는 reloadPermissions 호출 시)                     │
│                                                             │
│  1. localStorage 'user' → roleIds + menuIdToUrl 매핑 추출    │
│  2. API 호출: GET /api/v1/roles/{roleId}/permissions/matrix  │
│     → { permissions: { [menuId]: { view: true, ... } } }    │
│  3. menuId → URL 변환 (localStorage 메뉴 데이터 활용)          │
│     → { "/approval/draft": { view: true, create: false } }  │
│  4. 다중 역할: Union 병합                                     │
│  5. PermissionContext에 permissionMap 저장                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  페이지 접근 시 (PermissionGate, 자동)                        │
│                                                             │
│  usePathname() → "/ko/approval/draft"                       │
│  → locale 제거 → "/approval/draft"                           │
│  → longest prefix match → "/approval/draft"                  │
│  → permissionMap["/approval/draft"].view === false           │
│  → <AccessDenied /> 렌더링                                   │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│  버튼 제어 시 (usePermission 훅, 컴포넌트별)                   │
│                                                             │
│  const { canCreate, canDelete } = usePermission();          │
│  → canCreate === false → 생성/등록 버튼 숨김                  │
│  → canDelete === false → 삭제 버튼 숨김                       │
└─────────────────────────────────────────────────────────────┘

3. 파일 구조 및 역할

3.1 신규 생성 파일 (7개)

파일 역할
src/lib/permissions/types.ts PermissionAction, PermissionMap, UsePermissionReturn 타입
src/lib/permissions/actions.ts Server Action — getRolePermissionMatrix(roleId)
src/lib/permissions/utils.ts buildMenuIdToUrlMap, convertMatrixToPermissionMap, mergePermissionMaps, findMatchingUrl
src/contexts/PermissionContext.tsx PermissionProvider + PermissionGate + reloadPermissions
src/hooks/usePermission.ts URL 자동매칭 훅 (현재 페이지 or overrideUrl)
src/components/common/AccessDenied.tsx 접근 차단 UI (ServerErrorPage 패턴)
src/components/common/PermissionGuard.tsx 버튼 레벨 권한 래퍼 컴포넌트

3.2 수정 파일 (6개)

파일 수정 내용
src/contexts/RootProvider.tsx PermissionProvider 추가
src/app/[locale]/(protected)/layout.tsx PermissionGate 추가
src/components/templates/UniversalListPage/index.tsx usePermission() — createButton, onDelete 제어
src/components/templates/IntegratedDetailTemplate/index.tsx usePermission() — canEdit/canDelete/canCreate fallback
src/components/production/ProductionDashboard/index.tsx usePermission(overrideUrl) — 네비게이션 버튼 권한
src/components/settings/PermissionManagement/PermissionDetailClient.tsx reloadPermissions() 호출 (토글/전체허용/전체거부/초기화)

4. 핵심 구현 디테일

4.1 API 선택 — Role 기반 (중요)

백엔드에 2개의 서로 다른 API가 존재:

API Controller DB 테이블 용도
GET /api/v1/permissions/users/{id}/menu-matrix PermissionController model_has_permissions 유저 직접 할당 권한만
GET /api/v1/roles/{id}/permissions/matrix RolePermissionController role_has_permissions 역할 기반 권한 (설정 페이지와 동일)

설정 페이지가 Role 기반으로 저장/조회하므로, Provider도 동일한 API 사용.

4.2 누락 action = false 처리 (중요)

설정 페이지 API는 허용된 권한만 응답에 포함. 비허용 권한은 키 자체를 생략.

// API 응답 예시 (create 비허용)
{ "view": true, "update": true, "delete": true, "approve": true }
// → create 키 없음

// convertMatrixToPermissionMap에서 처리
map[url][action] = perms[action] === true;  // 누락 = false

4.3 menuId → URL 매핑

설정 페이지 API는 menuId로 키잉, 프론트는 URL 기반. localStorage 메뉴 데이터로 변환:

localStorage user.menu → [{ id: "123", path: "/approval/draft", children: [...] }]
                       → buildMenuIdToUrlMap → { "123": "/approval/draft" }

API permissions → { "123": { view: true, create: false } }

결합 → { "/approval/draft": { view: true, create: false } }

4.4 자기 잠금 방지

const BYPASS_PATHS = ['/settings/permissions'];
// PermissionGate에서 이 경로는 항상 통과 → 관리자가 자신의 권한 설정 페이지 접근을 막아도 복구 가능

4.5 reloadPermissions

설정 페이지에서 권한 변경 시 새로고침 없이 즉시 반영:

// PermissionDetailClient.tsx
const { reloadPermissions } = usePermissionContext();

// 토글/전체허용/전체거부/초기화 성공 후
reloadPermissions();

5. 디버깅 과정에서 발견된 이슈

5.1 AuthContext vs localStorage 키 불일치 (해결)

항목
로그인 시 저장 localStorage.setItem('user', ...) { id: 33, roles: [...], menu: [...] }
AuthContext 로드 localStorage.getItem('mes-currentUser') 별도 데이터

결론: AuthContext의 currentUser를 사용하면 안 됨. localStorage user 키에서 직접 읽기.

5.2 raw fetch vs serverFetch 래퍼 (해결)

방식 문제
fetch() 직접 사용 토큰 갱신 안 됨, 잘못된 API_KEY env var, Accept 헤더 누락
serverFetch() 래퍼 토큰 자동 갱신, 올바른 헤더, 401 처리

결론: 프로젝트의 모든 Server Action은 serverFetch() 사용 필수.

5.3 User API vs Role API (해결)

PermissionController.getMenuMatrix(scope='user')model_has_permissions 테이블만 조회. 설정 페이지의 토글은 role_has_permissions 테이블에 저장. → 서로 다른 테이블 → 데이터 불일치 → Role API로 전환.

5.4 API 응답에서 비허용 권한 생략 (해결)

RolePermissionController::matrix()는 허용된 권한만 포함, 비허용은 키 생략. → if (action in perms) 체크로는 비허용 감지 불가 → perms[action] === true 로 변경 (누락 = false)


6. 검수 상태

1차 검수 (2026-02-02) 완료

  • view 해제 → 페이지 접근 차단 (AccessDenied 표시)
  • create 해제 → 생성/등록 버튼 숨김 (UniversalListPage 기반 페이지)
  • 전체 허용 / 전체 거부 → 정상 반영
  • 설정 변경 → 새로고침 없이 즉시 반영 (reloadPermissions)
  • fail-open: API 실패 시 전체 허용

세부 검수 (미진행)

  • update 해제 → 수정 버튼 숨김 테스트
  • delete 해제 → 삭제 버튼 숨김 테스트
  • approve 해제 → 승인 버튼 숨김 테스트
  • IntegratedDetailTemplate 기반 페이지 검수
  • 커스텀 페이지(대시보드 등) 버튼 권한 검수
  • 다중 역할 유저 테스트 (Union 병합)

추가 기능 (미구현)

  • 사이드바 메뉴 필터링 (view=false 메뉴 숨김)
  • 디버그 console.log 제거 (usePermission.ts 등)

7. 정책 결정사항

상황 정책 이유
백엔드 미등록 URL 허용 권한 대상 외
API 실패 허용 (fail-open) 폐쇄형 시스템
권한 로딩 중 허용 UX 우선
action 키 누락 (API 미포함) 비허용 (false) API가 허용만 포함
다중 역할 충돌 Union (하나라도 허용이면 허용) 최대 권한 원칙
권한 설정 페이지 항상 접근 허용 자기 잠금 방지

8. 백엔드 참고

구분 파일 경로
RolePermissionController sam-api/app/Http/Controllers/Api/V1/RolePermissionController.php
RolePermissionService sam-api/app/Services/RolePermissionService.php
PermissionController sam-api/app/Http/Controllers/Api/V1/PermissionController.php
PermissionService sam-api/app/Services/PermissionService.php
Route 정의 sam-api/routes/api/v1/common.php (line 74-92)

문서 업데이트 이력:

  • 2025-01-20: 최초 작성 (menuCode 수동 매핑 방식)
  • 2026-02-02: 전략 전환 (menuCode → URL 자동매칭)
  • 2026-02-02: Phase 1~4 구현 완료
  • 2026-02-02: API 전환 (User API → Role API), 버그 수정 4건, 1차 검수 완료