Files
sam-react-prod/claudedocs/security/[PLAN-2025-12-12] tenant-data-isolation-implementation.md
byeongcheolryu b1587071f2 feat: 품목관리 기능 개선 및 문서화 업데이트
- 품목 상세/수정 페이지 파일 다운로드 기능 개선
- DynamicItemForm 파일 업로드 UI/UX 개선 (시방서, 인정서)
- BendingDiagramSection 조립/절곡 부품 전개도 통합
- API proxy route 품목 타입별 라우팅 개선
- ItemListClient 파일 다운로드 유틸리티 적용
- 품목코드 중복 체크 및 다이얼로그 추가

문서화:
- DynamicItemForm 훅 분리 계획서 추가 (2161줄 → 900줄 목표)
- 백엔드 API 마이그레이션 문서 추가
- 대용량 파일 처리 전략 가이드 추가
- 테넌트 데이터 격리 감사 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 11:01:25 +09:00

10 KiB

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

개요

항목 내용
목적 테넌트 간 데이터 오염/유출 방지
배경 로그아웃 후 캐시 잔존, 캐시 키 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 (신규 생성)

/**
 * 완전한 로그아웃 수행
 * - 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 주의사항

// ❌ 잘못된 구현: 전체 삭제 (다른 앱 데이터 삭제 위험)
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 현재 문제

// 현재: 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 조합

// 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 마이그레이션 전략

// 배포 시 기존 캐시 자동 정리 (일회성)
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 현재 문제

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

3.2 구현 계획

// 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 구현 계획

// 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)

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

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 불일치 시 차단