Files
sam-react-prod/claudedocs/security/[PLAN-2025-01-20] permission-system-implementation.md

249 lines
11 KiB
Markdown
Raw Normal View History

# 권한 시스템 구현 문서
> 작성일: 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는 **허용된 권한만** 응답에 포함. 비허용 권한은 키 자체를 생략.
```typescript
// 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 자기 잠금 방지
```typescript
const BYPASS_PATHS = ['/settings/permissions'];
// PermissionGate에서 이 경로는 항상 통과 → 관리자가 자신의 권한 설정 페이지 접근을 막아도 복구 가능
```
### 4.5 reloadPermissions
설정 페이지에서 권한 변경 시 새로고침 없이 즉시 반영:
```typescript
// 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) ✅ 완료
- [x] view 해제 → 페이지 접근 차단 (AccessDenied 표시)
- [x] create 해제 → 생성/등록 버튼 숨김 (UniversalListPage 기반 페이지)
- [x] 전체 허용 / 전체 거부 → 정상 반영
- [x] 설정 변경 → 새로고침 없이 즉시 반영 (reloadPermissions)
- [x] 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차 검수 완료**