~# 테넌트 데이터 격리 보안 강화 계획서

## 개요

| 항목 | 내용 |
|------|------|
| **목적** | 테넌트 간 데이터 오염/유출 방지 |
| **배경** | 로그아웃 후 캐시 잔존, 캐시 키 tenant.id 미포함 문제 발견 |
| **범위** | 프론트엔드 캐시 관리, API 프록시 검증 |
| **작성일** | 2025-12-12 |

---

## 우선순위 및 구현 범위

| 우선순위 | 항목 | 이유 | 예상 공수 | 상태 |
|:---:|------|------|:---:|:---:|
| 1 (필수) | 로그아웃 시 캐시 완전 정리 | 백엔드가 막을 수 없음 | 1시간 | ✅ 완료 (2025-12-14) |
| 2 (필수) | 캐시 키에 tenant.id 추가 | 백엔드가 막을 수 없음 | 2시간 | ⬜ |
| 3 (권장) | API 프록시 tenant.id 검증 | 이중 방어 (Defense in Depth) | 2시간 | ⬜ |
| 4 (선택) | tenant.id 주기적 검증 | 추가 안전장치 | 1시간 | ⬜ |

---

## 1. 로그아웃 시 캐시 완전 정리

### 1.1 현재 문제

```
로그아웃 시:
✅ HttpOnly 쿠키 삭제 (access_token, refresh_token)
❌ sessionStorage 캐시 잔존 (page_config_*)
❌ Zustand 메모리 캐시 잔존 (itemStore, masterDataStore)
❌ localStorage 사용자 데이터 잔존 (mes-currentUser)
```

### 1.2 구현 계획

#### 파일: `src/lib/auth/logout.ts` (신규 생성)

```typescript
/**
 * 완전한 로그아웃 수행
 * - Zustand 스토어 초기화
 * - sessionStorage 캐시 삭제
 * - localStorage 사용자 데이터 삭제
 * - 서버 로그아웃 API 호출
 */
export async function performFullLogout(): Promise<void> {
  // 1. Zustand 스토어 초기화
  // 2. sessionStorage 우리 앱 캐시만 삭제
  // 3. localStorage 사용자 데이터 삭제
  // 4. 서버 로그아웃 API 호출
  // 5. 로그인 페이지로 리다이렉트
}
```

#### 수정 파일 목록

| 파일 | 수정 내용 |
|------|----------|
| `src/lib/auth/logout.ts` | 신규 생성 - 통합 로그아웃 함수 |
| `src/contexts/AuthContext.tsx` | logout 함수에서 performFullLogout 호출 |
| `src/stores/masterDataStore.ts` | reset() 함수가 sessionStorage도 정리하도록 수정 |
| `src/stores/itemStore.ts` | reset() 함수 확인 (메모리만 사용 시 수정 불필요) |
| `src/layouts/AuthenticatedLayout.tsx` | handleLogout에서 AuthContext.logout() 호출 (기존 DashboardLayout.tsx) |

### 1.3 삭제 대상 캐시

| 저장소 | Prefix | 설명 |
|--------|--------|------|
| sessionStorage | `page_config_*` | 페이지 구성 캐시 |
| sessionStorage | `mes-*` | 테넌트 캐시 (TenantAwareCache) |
| localStorage | `mes-currentUser` | 현재 사용자 정보 |
| localStorage | `mes-users` | 사용자 목록 |
| Zustand | itemStore | 품목 메모리 캐시 |
| Zustand | masterDataStore | 페이지 구성 메모리 캐시 |

### 1.4 주의사항

```typescript
// ❌ 잘못된 구현: 전체 삭제 (다른 앱 데이터 삭제 위험)
sessionStorage.clear();

// ✅ 올바른 구현: 우리 앱 prefix만 삭제
const prefixes = ['page_config_', 'mes-'];
Object.keys(sessionStorage).forEach(key => {
  if (prefixes.some(p => key.startsWith(p))) {
    sessionStorage.removeItem(key);
  }
});
```

---

## 2. 캐시 키에 tenant.id 추가

### 2.1 현재 문제

```typescript
// 현재: tenant.id 없음
const key = `page_config_item-master`;

// 문제: 다른 tenant 사용자가 같은 브라우저 사용 시 캐시 충돌
// tenant 100 사용자 → page_config_item-master 저장
// tenant 200 사용자 → 같은 키에서 tenant 100 데이터 읽음 ⚠️
```

### 2.2 구현 계획

#### 변경 전/후 비교

| 구분 | 변경 전 | 변경 후 |
|------|---------|---------|
| 캐시 키 형식 | `page_config_item-master` | `page_config_282_item-master` |
| tenant.id 소스 | 없음 | 파라미터로 전달 |

#### 수정 파일 목록

| 파일 | 수정 내용 |
|------|----------|
| `src/stores/masterDataStore.ts` | 캐시 키에 tenantId 포함 |
| `src/components/*/` | fetchPageConfig 호출 시 tenantId 전달 |

### 2.3 tenant.id 전달 방식 결정

#### 옵션 비교

| 옵션 | 장점 | 단점 | 권장 |
|------|------|------|:---:|
| A. 파라미터 전달 | 명시적, 추적 용이 | 호출부 전부 수정 필요 | ✅ |
| B. Zustand 상태 추가 | 한 곳에서 관리 | store 간 의존성 발생 | |
| C. TenantAwareCache 활용 | 이미 구현됨 | 생성자에 tenantId 필요 | ✅ |

#### 권장: 옵션 A + C 조합

```typescript
// masterDataStore.ts
import { TenantAwareCache } from '@/lib/cache/TenantAwareCache';

fetchPageConfig: async (pageType: PageType, tenantId: number) => {
  const cache = new TenantAwareCache(tenantId, sessionStorage, CACHE_TTL);

  // 캐시 조회 (tenant.id 자동 검증)
  const cached = cache.get<PageConfig>(`page_config_${pageType}`);
  if (cached) return cached;

  // API 조회 후 캐시 저장
  const config = await fetchPageConfigByType(pageType);
  if (config) {
    cache.set(`page_config_${pageType}`, config);
  }
  return config;
}
```

### 2.4 마이그레이션 전략

```typescript
// 배포 시 기존 캐시 자동 정리 (일회성)
function migrateOldCache() {
  const oldPatterns = [
    'page_config_item-master',
    'page_config_quotation',
    'page_config_sales-order',
    'page_config_formula',
    'page_config_pricing',
  ];

  oldPatterns.forEach(key => {
    if (sessionStorage.getItem(key)) {
      sessionStorage.removeItem(key);
      console.log(`[Migration] Removed old cache: ${key}`);
    }
  });
}
```

---

## 3. API 프록시 tenant.id 검증 (권장)

### 3.1 현재 문제

```typescript
// src/app/api/proxy/[...path]/route.ts
const backendUrl = `${API_URL}/api/v1/${params.path.join('/')}`;
// → URL에 tenantId가 있어도 검증 없이 백엔드로 전달
```

### 3.2 구현 계획

```typescript
// JWT 디코딩 라이브러리 설치
npm install jwt-decode

// 프록시에서 검증 추가
import { jwtDecode } from 'jwt-decode';

async function proxyRequest(...) {
  const token = request.cookies.get('access_token')?.value;
  const decoded = jwtDecode<{ tenant_id: number }>(token);

  // URL에서 tenantId 추출
  const urlTenantMatch = pathStr.match(/tenants\/(\d+)/);
  if (urlTenantMatch) {
    const urlTenantId = parseInt(urlTenantMatch[1]);

    // 불일치 시 차단
    if (urlTenantId !== decoded.tenant_id) {
      return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
    }
  }
}
```

### 3.3 수정 파일

| 파일 | 수정 내용 |
|------|----------|
| `package.json` | jwt-decode 의존성 추가 |
| `src/app/api/proxy/[...path]/route.ts` | tenant.id 검증 로직 추가 |

---

## 4. tenant.id 주기적 검증 (선택)

### 4.1 구현 계획

```typescript
// src/contexts/AuthContext.tsx
useEffect(() => {
  const validateTenant = async () => {
    const response = await fetch('/api/auth/check');
    const data = await response.json();

    if (data.tenant?.id !== currentUser?.tenant?.id) {
      console.error('Tenant mismatch! Forcing logout.');
      logout();
    }
  };

  const interval = setInterval(validateTenant, 5 * 60 * 1000); // 5분
  return () => clearInterval(interval);
}, [currentUser?.tenant?.id]);
```

---

## 체크리스트

### Phase 1: 로그아웃 캐시 정리 (필수) ✅ 완료 (2025-12-14)

- [x] `src/lib/auth/logout.ts` 생성
  - [x] Zustand 스토어 초기화 함수 호출 (`resetZustandStores`)
  - [x] sessionStorage prefix 기반 삭제 (`clearSessionStorageCache`)
  - [x] localStorage 사용자 데이터 삭제 (`clearLocalStorageCache`)
  - [x] 서버 로그아웃 API 호출 (`callLogoutAPI`)
  - [x] 리다이렉트 옵션 지원 (`redirectTo` 파라미터)
- [x] `src/stores/masterDataStore.ts` 수정
  - [x] reset() 함수에 sessionStorage 정리 추가
- [x] `src/contexts/AuthContext.tsx` 수정
  - [x] logout 함수에서 performFullLogout 호출
  - [x] logout 함수 타입 `Promise<void>`로 변경
- [x] `src/layouts/AuthenticatedLayout.tsx` 수정 (2025-12-14 추가)
  - [x] 직접 API 호출 → AuthContext.logout() 호출로 변경
  - [x] 파일명 DashboardLayout.tsx → AuthenticatedLayout.tsx 변경
- [x] 테스트 ✅ 완료 (2025-12-14)
  - [x] 로그아웃 후 sessionStorage 비어있는지 확인
  - [x] 로그아웃 후 Zustand DevTools에서 초기화 확인
  - [x] 다른 계정 로그인 시 이전 데이터 안 보이는지 확인

### Phase 2: 캐시 키 tenant.id 추가 (필수)

- [ ] `src/stores/masterDataStore.ts` 수정
  - [ ] TenantAwareCache import
  - [ ] fetchPageConfig 시그니처에 tenantId 추가
  - [ ] 캐시 키 생성 시 tenantId 포함
  - [ ] getConfigFromSessionStorage 함수 수정
  - [ ] setConfigToSessionStorage 함수 수정
- [ ] 호출부 수정
  - [ ] fetchPageConfig 호출하는 모든 컴포넌트에서 tenantId 전달
- [ ] 마이그레이션
  - [ ] 기존 형식 캐시 자동 정리 로직 추가
- [ ] 테스트
  - [ ] sessionStorage에 tenant.id 포함된 키 생성 확인
  - [ ] 다른 tenant 캐시와 격리되는지 확인

### Phase 3: API 프록시 검증 (권장)

- [ ] `npm install jwt-decode` 실행
- [ ] `src/app/api/proxy/[...path]/route.ts` 수정
  - [ ] JWT 디코딩 함수 추가
  - [ ] URL tenant.id 추출 로직 추가
  - [ ] 불일치 시 403 반환 로직 추가
- [ ] 테스트
  - [ ] 정상 요청 통과 확인
  - [ ] URL 조작 시 403 반환 확인

### Phase 4: 주기적 검증 (선택)

- [ ] `src/contexts/AuthContext.tsx` 수정
  - [ ] 5분 간격 검증 useEffect 추가
- [ ] 테스트
  - [ ] localStorage 조작 시 강제 로그아웃 확인

---

## 예상 부작용 및 대응

| 부작용 | 심각도 | 대응 |
|--------|:------:|------|
| 재로그인 시 캐시 miss로 느려짐 | 🟢 낮음 | 수용 (로그아웃은 빈번하지 않음) |
| 기존 캐시 무효화 | 🟢 낮음 | 마이그레이션 로직으로 자동 정리 |
| 호출부 수정 필요 | 🟡 중간 | 점진적 적용 가능 |

---

## 완료 기준

1. ✅ 로그아웃 후 sessionStorage, Zustand 캐시 완전 삭제
2. ✅ 다른 tenant 사용자 로그인 시 이전 데이터 노출 없음
3. ✅ 캐시 키에 tenant.id 포함되어 격리됨
4. ✅ (권장) API 프록시에서 tenant.id 불일치 시 차단