From fb201cc799f60192d7329f41ea910c02499be4b8 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 6 Nov 2025 18:35:05 +0900 Subject: [PATCH] =?UTF-8?q?=ED=9A=8C=EC=82=AC=EB=B3=84=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/multi-tenancy-guide.mdc | 844 ++++++++++++++++++ .cursorrules | 18 + .../src/controllers/entityJoinController.ts | 27 + frontend/app/(main)/admin/tableMng/page.tsx | 14 +- frontend/lib/api/entityJoin.ts | 8 + frontend/lib/api/screen.ts | 12 +- 6 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 .cursor/rules/multi-tenancy-guide.mdc diff --git a/.cursor/rules/multi-tenancy-guide.mdc b/.cursor/rules/multi-tenancy-guide.mdc new file mode 100644 index 00000000..3b79dd63 --- /dev/null +++ b/.cursor/rules/multi-tenancy-guide.mdc @@ -0,0 +1,844 @@ +--- +priority: critical +applies_to: all +check_frequency: always +enforcement: mandatory +--- + +# 멀티테넌시(Multi-Tenancy) 필수 구현 가이드 + +**🚨 최우선 보안 규칙: 이 문서의 모든 규칙은 예외 없이 반드시 준수해야 합니다.** + +**⚠️ AI 에이전트는 모든 코드 작성/수정 후 반드시 이 체크리스트를 확인해야 합니다.** + +## 핵심 원칙 + +**모든 비즈니스 데이터는 회사별(company_code)로 완벽하게 격리되어야 합니다.** + +이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다. +다른 회사의 데이터에 접근하는 것은 **치명적인 보안 취약점**입니다. + +--- + +## 1. 데이터베이스 스키마 요구사항 + +### 1.1 company_code 컬럼 필수 + +**모든 비즈니스 테이블은 `company_code` 컬럼을 반드시 포함해야 합니다.** + +```sql +CREATE TABLE example_table ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, -- ✅ 필수! + name VARCHAR(100), + created_at TIMESTAMPTZ DEFAULT NOW(), + + -- 외래키 제약조건 (필수) + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_mng(company_code) + ON DELETE CASCADE ON UPDATE CASCADE +); + +-- 성능을 위한 인덱스 (필수) +CREATE INDEX idx_example_company_code ON example_table(company_code); + +-- 복합 유니크 제약조건 (중복 방지) +CREATE UNIQUE INDEX idx_example_unique +ON example_table(name, company_code); -- 회사별로 고유해야 하는 경우 +``` + +### 1.2 예외 테이블 (company_code 불필요) + +**⚠️ 유일한 예외: `company_mng` 테이블만 `company_code`가 없습니다.** + +이 테이블은 회사 정보를 저장하는 마스터 테이블이므로 예외입니다. + +**모든 다른 테이블은 예외 없이 `company_code`가 필수입니다:** + +- ✅ `user_info` → `company_code` 필수 (사용자는 특정 회사 소속) +- ✅ `menu_info` → `company_code` 필수 (회사별 메뉴 설정 가능) +- ✅ `system_config` → `company_code` 필수 (회사별 시스템 설정) +- ✅ `audit_log` → `company_code` 필수 (회사별 감사 로그) +- ✅ 모든 비즈니스 테이블 → `company_code` 필수 + +**새로운 테이블 생성 시 체크리스트:** + +- [ ] `company_mng` 테이블인가? → `company_code` 불필요 (유일한 예외) +- [ ] 그 외 모든 테이블 → `company_code` 필수 (예외 없음) +- [ ] `company_code` 없이 테이블을 만들려고 하는가? → 다시 생각하세요! + +--- + +## 2. 백엔드 API 구현 필수 사항 + +### 2.1 모든 데이터 조회 시 필터링 + +**절대 원칙: 모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드 + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 가능 + query = ` + SELECT * FROM example_table + ORDER BY created_at DESC + `; + params = []; + logger.info("최고 관리자 전체 데이터 조회"); + } else { + // 일반 회사: 자신의 회사 데이터만 조회 + query = ` + SELECT * FROM example_table + WHERE company_code = $1 + ORDER BY created_at DESC + `; + params = [companyCode]; + logger.info("회사별 데이터 조회", { companyCode }); + } + + const result = await pool.query(query, params); + + return res.json({ success: true, data: result.rows }); +} +``` + +#### ❌ 잘못된 방법 - 절대 사용 금지 + +```typescript +// 🚨 치명적 보안 취약점: company_code 필터링 없음 +async function getDataList(req: Request, res: Response) { + const query = `SELECT * FROM example_table`; // 모든 회사 데이터 노출! + const result = await pool.query(query); + return res.json({ success: true, data: result.rows }); +} +``` + +### 2.2 데이터 생성 (INSERT) + +**모든 INSERT 쿼리는 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function createData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; // 서버에서 확정 + const { name, description } = req.body; + + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + + const result = await pool.query(query, [companyCode, name, description]); + + logger.info("데이터 생성", { + companyCode, + id: result.rows[0].id, + }); + + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### ❌ 클라이언트 입력 사용 금지 + +```typescript +// 🚨 보안 취약점: 클라이언트가 임의의 회사 코드 지정 가능 +async function createData(req: Request, res: Response) { + const { companyCode, name } = req.body; // 사용자가 다른 회사 코드 전달 가능! + const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`; + await pool.query(query, [companyCode, name]); +} +``` + +### 2.3 데이터 수정 (UPDATE) + +**WHERE 절에 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function updateData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { name, description } = req.body; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 데이터 수정 가능 + query = ` + UPDATE example_table + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 + RETURNING * + `; + params = [name, description, id]; + } else { + // 일반 회사: 자신의 데이터만 수정 가능 + query = ` + UPDATE example_table + SET name = $1, description = $2, updated_at = NOW() + WHERE id = $3 AND company_code = $4 + RETURNING * + `; + params = [name, description, id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다", + }); + } + + logger.info("데이터 수정", { companyCode, id }); + + return res.json({ success: true, data: result.rows[0] }); +} +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 다른 회사의 같은 ID 데이터도 수정됨 +const query = ` + UPDATE example_table + SET name = $1, description = $2 + WHERE id = $3 +`; +``` + +### 2.4 데이터 삭제 (DELETE) + +**WHERE 절에 company_code를 반드시 포함해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +async function deleteData(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 데이터 삭제 가능 + query = `DELETE FROM example_table WHERE id = $1 RETURNING id`; + params = [id]; + } else { + // 일반 회사: 자신의 데이터만 삭제 가능 + query = ` + DELETE FROM example_table + WHERE id = $1 AND company_code = $2 + RETURNING id + `; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "데이터를 찾을 수 없거나 권한이 없습니다", + }); + } + + logger.info("데이터 삭제", { companyCode, id }); + + return res.json({ success: true }); +} +``` + +--- + +## 3. company_code = "\*" 의 의미 + +### 3.1 최고 관리자 전용 데이터 + +**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다. + +- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터 +- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터 + +### 3.2 데이터 격리 원칙 + +**회사별 데이터 접근 규칙:** + +| 사용자 유형 | company_code | 접근 가능한 데이터 | +| ----------- | ------------ | ---------------------------------------------- | +| 회사 A | `COMPANY_A` | `company_code = 'COMPANY_A'` 데이터만 | +| 회사 B | `COMPANY_B` | `company_code = 'COMPANY_B'` 데이터만 | +| 최고 관리자 | `*` | 모든 회사 데이터 + `company_code = '*'` 데이터 | + +**핵심**: + +- 일반 회사는 `company_code = "*"` 데이터를 **절대 볼 수 없음** +- 일반 회사는 다른 회사의 데이터를 **절대 볼 수 없음** +- 최고 관리자만 모든 데이터에 접근 가능 + +--- + +## 4. 복잡한 쿼리에서의 멀티테넌시 + +### 4.1 JOIN 쿼리 + +**모든 JOIN된 테이블에도 company_code 필터링을 적용해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT + a.*, + b.name as category_name, + c.name as user_name + FROM example_table a + LEFT JOIN category_table b + ON a.category_id = b.id + AND a.company_code = b.company_code -- ✅ JOIN 조건에도 company_code 필수 + LEFT JOIN user_info c + ON a.user_id = c.user_id + AND a.company_code = c.company_code + WHERE a.company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: JOIN에서 다른 회사 데이터와 섞임 +const query = ` + SELECT + a.*, + b.name as category_name + FROM example_table a + LEFT JOIN category_table b ON a.category_id = b.id -- company_code 없음! + WHERE a.company_code = $1 +`; +``` + +### 4.2 서브쿼리 + +**모든 서브쿼리에도 company_code 필터링을 적용해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table + WHERE active = true AND company_code = $1 -- ✅ + ) + AND company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 서브쿼리에서 company_code 누락 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table WHERE active = true -- company_code 없음! + ) + AND company_code = $1 +`; +``` + +### 4.3 집계 함수 (COUNT, SUM 등) + +**집계 함수도 company_code로 필터링해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +const query = ` + SELECT COUNT(*) as total + FROM example_table + WHERE company_code = $1 +`; +``` + +#### ❌ 잘못된 방법 + +```typescript +// 🚨 보안 취약점: 모든 회사의 총합 반환 +const query = `SELECT COUNT(*) as total FROM example_table`; +``` + +### 4.4 EXISTS 서브쿼리 + +```typescript +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table a + WHERE EXISTS ( + SELECT 1 FROM related_table b + WHERE b.example_id = a.id + AND b.company_code = a.company_code -- ✅ 필수 + ) + AND a.company_code = $1 +`; +``` + +--- + +## 5. 자동 필터 시스템 (autoFilter) + +### 5.1 백엔드 구현 (이미 완료) + +백엔드에는 `autoFilter` 기능이 구현되어 있습니다: + +```typescript +// tableManagementController.ts +let enhancedSearch = { ...search }; +if (autoFilter?.enabled && req.user) { + const filterColumn = autoFilter.filterColumn || "company_code"; + const userField = autoFilter.userField || "companyCode"; + const userValue = (req.user as any)[userField]; + + if (userValue) { + enhancedSearch[filterColumn] = userValue; + logger.info("🔍 현재 사용자 필터 적용:", { + filterColumn, + userValue, + tableName, + }); + } +} +``` + +### 5.2 프론트엔드 사용 (필수) + +**모든 테이블 데이터 API 호출 시 `autoFilter`를 반드시 전달해야 합니다.** + +#### ✅ 올바른 방법 + +```typescript +// frontend/lib/api/screen.ts +const requestBody = { + ...params, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, +}; + +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + requestBody +); +``` + +#### Entity 조인 API + +```typescript +// frontend/lib/api/entityJoin.ts +const autoFilter = { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", +}; + +const response = await apiClient.get( + `/table-management/tables/${tableName}/data-with-joins`, + { + params: { + ...params, + autoFilter: JSON.stringify(autoFilter), + }, + } +); +``` + +--- + +## 6. 서비스 계층 패턴 + +### 6.1 표준 서비스 함수 패턴 + +**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다.** + +```typescript +class ExampleService { + async findAll(companyCode: string, filters?: any) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `SELECT * FROM example_table`; + params = []; + } else { + query = `SELECT * FROM example_table WHERE company_code = $1`; + params = [companyCode]; + } + + return await pool.query(query, params); + } + + async findById(companyCode: string, id: number) { + let query: string; + let params: any[]; + + if (companyCode === "*") { + query = `SELECT * FROM example_table WHERE id = $1`; + params = [id]; + } else { + query = `SELECT * FROM example_table WHERE id = $1 AND company_code = $2`; + params = [id, companyCode]; + } + + const result = await pool.query(query, params); + return result.rows[0]; + } + + async create(companyCode: string, data: any) { + const query = ` + INSERT INTO example_table (company_code, name, description) + VALUES ($1, $2, $3) + RETURNING * + `; + const result = await pool.query(query, [ + companyCode, + data.name, + data.description, + ]); + return result.rows[0]; + } +} + +// 컨트롤러에서 사용 +const exampleService = new ExampleService(); + +async function getDataList(req: Request, res: Response) { + const companyCode = req.user!.companyCode; + const data = await exampleService.findAll(companyCode, req.query); + return res.json({ success: true, data }); +} +``` + +--- + +## 7. 마이그레이션 체크리스트 + +### 7.1 새로운 테이블 생성 시 + +- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가 +- [ ] `company_mng` 테이블에 대한 외래키 제약조건 추가 +- [ ] `company_code`에 인덱스 생성 +- [ ] 복합 유니크 제약조건에 `company_code` 포함 +- [ ] 샘플 데이터에 올바른 `company_code` 값 포함 + +### 7.2 기존 테이블 마이그레이션 시 + +```sql +-- 1. company_code 컬럼 추가 +ALTER TABLE example_table ADD COLUMN company_code VARCHAR(20); + +-- 2. 기존 데이터를 모든 회사별로 복제 +INSERT INTO example_table (company_code, name, description, created_at) +SELECT ci.company_code, et.name, et.description, et.created_at +FROM (SELECT * FROM example_table WHERE company_code IS NULL) et +CROSS JOIN company_mng ci +WHERE NOT EXISTS ( + SELECT 1 FROM example_table et2 + WHERE et2.name = et.name + AND et2.company_code = ci.company_code +); + +-- 3. NULL 데이터 삭제 +DELETE FROM example_table WHERE company_code IS NULL; + +-- 4. NOT NULL 제약조건 +ALTER TABLE example_table ALTER COLUMN company_code SET NOT NULL; + +-- 5. 인덱스 및 외래키 +CREATE INDEX idx_example_company ON example_table(company_code); +ALTER TABLE example_table +ADD CONSTRAINT fk_example_company +FOREIGN KEY (company_code) REFERENCES company_mng(company_code) +ON DELETE CASCADE ON UPDATE CASCADE; +``` + +--- + +## 8. 테스트 체크리스트 + +### 8.1 필수 테스트 시나리오 + +**모든 새로운 API는 다음 테스트를 통과해야 합니다:** + +- [ ] **회사 A 테스트**: 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인 +- [ ] **회사 B 테스트**: 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인 +- [ ] **격리 테스트**: 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인 +- [ ] **최고 관리자 테스트**: 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인 +- [ ] **수정 권한 테스트**: 회사 A가 회사 B의 데이터를 수정할 수 없는지 확인 +- [ ] **삭제 권한 테스트**: 회사 A가 회사 B의 데이터를 삭제할 수 없는지 확인 + +### 8.2 SQL 인젝션 테스트 + +```typescript +// company_code를 URL 파라미터로 전달하려는 시도 차단 +// ❌ 이런 요청을 받아서는 안 됨 +GET /api/data?company_code=COMPANY_B + +// ✅ company_code는 항상 req.user에서 가져와야 함 +const companyCode = req.user!.companyCode; +``` + +--- + +## 9. 감사 로그 (Audit Log) + +### 9.1 모든 중요 작업에 로깅 + +```typescript +logger.info("데이터 생성", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + tableName: "example_table", + action: "INSERT", + recordId: result.rows[0].id, +}); + +logger.warn("권한 없는 접근 시도", { + companyCode: req.user!.companyCode, + userId: req.user!.userId, + attemptedRecordId: req.params.id, + message: "다른 회사의 데이터 접근 시도", +}); +``` + +### 9.2 감사 로그 테이블 구조 + +```sql +CREATE TABLE audit_log ( + id SERIAL PRIMARY KEY, + company_code VARCHAR(20) NOT NULL, + user_id VARCHAR(100) NOT NULL, + action VARCHAR(50) NOT NULL, + table_name VARCHAR(100), + record_id VARCHAR(100), + old_value JSONB, + new_value JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_audit_company ON audit_log(company_code); +CREATE INDEX idx_audit_action ON audit_log(action, created_at); +``` + +--- + +## 10. 보안 체크리스트 (코드 리뷰 시 필수) + +### 10.1 백엔드 API 체크리스트 + +- [ ] 모든 SELECT 쿼리에 `WHERE company_code = $1` 포함 (최고 관리자 예외) +- [ ] 모든 INSERT 쿼리에 `company_code` 컬럼 포함 +- [ ] 모든 UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 포함 +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 포함 +- [ ] 서브쿼리에 `company_code` 필터링 포함 +- [ ] 집계 함수에 `company_code` 필터링 포함 +- [ ] `req.user.companyCode` 사용 (클라이언트 입력 사용 금지) +- [ ] 최고 관리자(`company_code = "*"`) 예외 처리 +- [ ] 로그에 `companyCode` 정보 포함 +- [ ] 권한 없음 시 404 또는 403 반환 + +### 10.2 프론트엔드 체크리스트 + +- [ ] 모든 테이블 데이터 API 호출 시 `autoFilter` 전달 +- [ ] `company_code`를 직접 전달하지 않음 (백엔드에서 자동 처리) +- [ ] 에러 발생 시 적절한 메시지 표시 + +### 10.3 데이터베이스 체크리스트 + +- [ ] 모든 비즈니스 테이블에 `company_code` 컬럼 존재 +- [ ] `company_code`에 NOT NULL 제약조건 적용 +- [ ] `company_code`에 인덱스 생성 +- [ ] 외래키 제약조건으로 `company_mng` 참조 +- [ ] 복합 유니크 제약조건에 `company_code` 포함 + +--- + +## 11. 일반적인 실수와 해결방법 + +### 실수 1: 서브쿼리에서 company_code 누락 + +```typescript +// ❌ 잘못된 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table WHERE active = true + ) + AND company_code = $1 +`; + +// ✅ 올바른 방법 +const query = ` + SELECT * FROM example_table + WHERE category_id IN ( + SELECT id FROM category_table + WHERE active = true AND company_code = $1 + ) + AND company_code = $1 +`; +``` + +### 실수 2: COUNT/SUM 집계 함수 + +```typescript +// ❌ 잘못된 방법 - 모든 회사의 총합 +const query = `SELECT COUNT(*) as total FROM example_table`; + +// ✅ 올바른 방법 +const query = ` + SELECT COUNT(*) as total + FROM example_table + WHERE company_code = $1 +`; +``` + +### 실수 3: autoFilter 누락 + +```typescript +// ❌ 잘못된 방법 +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page, + size, + search, + } +); + +// ✅ 올바른 방법 +const response = await apiClient.post( + `/table-management/tables/${tableName}/data`, + { + page, + size, + search, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, + } +); +``` + +--- + +## 12. 참고 자료 + +### 완료된 구현 예시 + +- **테이블 데이터 API**: `backend-node/src/controllers/tableManagementController.ts` (getTableData) +- **Entity 조인 API**: `backend-node/src/controllers/entityJoinController.ts` (getTableDataWithJoins) +- **카테고리 값 API**: `backend-node/src/services/tableCategoryValueService.ts` (getCategoryValues) +- **프론트엔드 API**: `frontend/lib/api/screen.ts` (getTableData) +- **프론트엔드 Entity 조인**: `frontend/lib/api/entityJoin.ts` (getTableDataWithJoins) + +### 마이그레이션 스크립트 + +- `db/migrations/044_simple_version.sql` - table_type_columns에 company_code 추가 +- `db/migrations/045_add_company_code_to_category_values.sql` - 카테고리 값 테이블 마이그레이션 + +--- + +## 요약: 절대 잊지 말아야 할 핵심 규칙 + +### 데이터베이스 + +1. **모든 테이블에 `company_code` 필수** (`company_mng` 제외) +2. **인덱스와 외래키 필수** +3. **복합 유니크 제약조건에 `company_code` 포함** + +### 백엔드 API + +1. **모든 SELECT 쿼리**: `WHERE company_code = $1` (최고 관리자 제외) +2. **모든 INSERT 쿼리**: `company_code` 컬럼 포함 +3. **모든 UPDATE/DELETE 쿼리**: WHERE 절에 `company_code` 조건 포함 +4. **JOIN/서브쿼리/집계**: 모두 `company_code` 필터링 필수 + +### 프론트엔드 + +1. **모든 테이블 데이터 API 호출**: `autoFilter` 전달 필수 +2. **`company_code`를 직접 전달 금지**: 백엔드에서 자동 처리 + +--- + +**🚨 멀티테넌시는 보안의 핵심입니다. 예외 없이 모든 규칙을 준수하세요!** + +**⚠️ company_code = "\*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!** + +**✅ 모든 테이블에 company_code 필수! (company_mng 제외)** + +--- + +## 🤖 AI 에이전트 필수 체크리스트 + +**모든 코드 작성/수정 완료 후 반드시 다음을 확인하세요:** + +### 데이터베이스 마이그레이션을 작성했다면: + +- [ ] `company_code VARCHAR(20) NOT NULL` 컬럼 추가했는가? +- [ ] `company_code`에 인덱스를 생성했는가? +- [ ] `company_mng` 테이블에 대한 외래키를 추가했는가? +- [ ] 복합 유니크 제약조건에 `company_code`를 포함했는가? +- [ ] 기존 데이터를 모든 회사별로 복제했는가? + +### 백엔드 API를 작성/수정했다면: + +- [ ] SELECT 쿼리에 `WHERE company_code = $1` 조건이 있는가? (최고 관리자 제외) +- [ ] INSERT 쿼리에 `company_code` 컬럼이 포함되어 있는가? +- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건이 있는가? +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건이 있는가? +- [ ] 서브쿼리에 `company_code` 필터링이 있는가? +- [ ] 집계 함수(COUNT, SUM 등)에 `company_code` 필터링이 있는가? +- [ ] `req.user.companyCode`를 사용하고 있는가? (클라이언트 입력 사용 금지) +- [ ] 최고 관리자(`company_code = "*"`) 예외 처리를 했는가? +- [ ] 로그에 `companyCode` 정보를 포함했는가? +- [ ] 권한 없음 시 적절한 HTTP 상태 코드(404/403)를 반환하는가? + +### 프론트엔드 API 호출을 작성/수정했다면: + +- [ ] `autoFilter` 옵션을 전달하고 있는가? +- [ ] `autoFilter.enabled = true`로 설정했는가? +- [ ] `autoFilter.filterColumn = "company_code"`로 설정했는가? +- [ ] `autoFilter.userField = "companyCode"`로 설정했는가? +- [ ] `company_code`를 직접 전달하지 않았는가? (백엔드 자동 처리) + +### 테스트를 수행했다면: + +- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인했는가? +- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인했는가? +- [ ] 회사 A가 회사 B 데이터에 접근할 수 없는지 확인했는가? +- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인했는가? + +--- + +**⚠️ 위 체크리스트 중 하나라도 "아니오"가 있다면, 코드를 다시 검토하세요!** + +**🚨 멀티테넌시 위반은 치명적인 보안 취약점입니다!** diff --git a/.cursorrules b/.cursorrules index abbc2994..3b0c3833 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,5 +1,23 @@ # Cursor Rules for ERP-node Project +## 🚨 최우선 보안 규칙: 멀티테넌시 + +**모든 코드 작성/수정 완료 후 반드시 다음 파일을 확인하세요:** +- [멀티테넌시 필수 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) + +**AI 에이전트는 다음 상황에서 반드시 멀티테넌시 체크리스트를 확인해야 합니다:** +1. 데이터베이스 마이그레이션 작성 시 +2. 백엔드 API (SELECT/INSERT/UPDATE/DELETE) 작성/수정 시 +3. 프론트엔드 데이터 API 호출 작성/수정 시 +4. 테스트 완료 시 + +**핵심 원칙:** +- ✅ 모든 테이블에 `company_code` 필수 (company_mng 제외) +- ✅ 모든 쿼리에 `company_code` 필터링 필수 +- ✅ 프론트엔드 API 호출 시 `autoFilter` 전달 필수 + +--- + ## shadcn/ui 웹 스타일 가이드라인 모든 프론트엔드 개발 시 다음 shadcn/ui 기반 스타일 가이드라인을 준수해야 합니다. diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index 77fdb0dd..8649cea5 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -27,6 +27,7 @@ export class EntityJoinController { enableEntityJoin = true, additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) + autoFilter, // 🔒 멀티테넌시 자동 필터 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 ...otherParams } = req.query; @@ -36,6 +37,7 @@ export class EntityJoinController { size, enableEntityJoin, search, + autoFilter, }); // 검색 조건 처리 @@ -51,6 +53,31 @@ export class EntityJoinController { } } + // 🔒 멀티테넌시: 자동 필터 처리 + if (autoFilter) { + try { + const parsedAutoFilter = + typeof autoFilter === "string" ? JSON.parse(autoFilter) : autoFilter; + + if (parsedAutoFilter.enabled && (req as any).user) { + const filterColumn = parsedAutoFilter.filterColumn || "company_code"; + const userField = parsedAutoFilter.userField || "companyCode"; + const userValue = ((req as any).user as any)[userField]; + + if (userValue) { + searchConditions[filterColumn] = userValue; + logger.info("🔒 Entity 조인에 멀티테넌시 필터 적용:", { + filterColumn, + userValue, + tableName, + }); + } + } + } catch (error) { + logger.warn("자동 필터 파싱 오류:", error); + } + } + // 추가 조인 컬럼 정보 처리 let parsedAdditionalJoinColumns: any[] = []; if (additionalJoinColumns) { diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 0e30d32d..238ee616 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -1003,7 +1003,7 @@ export default function TableManagementPage() {
- {/* 웹 타입이 'code'인 경우 공통코드 선택 */} + {/* 입력 타입이 'code'인 경우 공통코드 선택 */} {column.inputType === "code" && ( )} - {/* 웹 타입이 'entity'인 경우 참조 테이블 선택 */} + {/* 입력 타입이 'category'인 경우 안내 메시지 */} + {column.inputType === "category" && ( +
+ 메뉴별 카테고리 값이 자동으로 표시됩니다 +
+ )} + {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && (
@@ -1165,8 +1171,8 @@ export default function TableManagementPage() { )}
)} - {/* 다른 웹 타입인 경우 빈 공간 */} - {column.inputType !== "code" && column.inputType !== "entity" && ( + {/* 다른 입력 타입인 경우 빈 공간 */} + {column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
-
diff --git a/frontend/lib/api/entityJoin.ts b/frontend/lib/api/entityJoin.ts index dee758a5..d26e42b9 100644 --- a/frontend/lib/api/entityJoin.ts +++ b/frontend/lib/api/entityJoin.ts @@ -89,12 +89,20 @@ export const entityJoinApi = { }); } + // 🔒 멀티테넌시: company_code 자동 필터링 활성화 + const autoFilter = { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }; + const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params: { ...params, search: params.search ? JSON.stringify(params.search) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 + autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 }, }); return response.data.data; diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index a37fbdff..d1d07d96 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -280,7 +280,17 @@ export const tableTypeApi = { size: number; totalPages: number; }> => { - const response = await apiClient.post(`/table-management/tables/${tableName}/data`, params); + // 🔒 멀티테넌시: company_code 자동 필터링 활성화 + const requestBody = { + ...params, + autoFilter: { + enabled: true, + filterColumn: "company_code", + userField: "companyCode", + }, + }; + + const response = await apiClient.post(`/table-management/tables/${tableName}/data`, requestBody); const raw = response.data?.data || response.data; return {