회사별 메뉴 분리 및 권한 관리

This commit is contained in:
kjs
2025-10-28 10:07:07 +09:00
parent 35581ac8d2
commit 25f6217433
13 changed files with 1273 additions and 370 deletions
+502
View File
@@ -947,3 +947,505 @@ const visibleUsers = users.filter(user => {
- API 응답에 최고 관리자 정보가 절대 포함되어서는 안 됩니다.
- 로그에 필터링 여부를 기록하여 감사 추적을 남기세요.
---
## 멀티테넌시(Multi-Tenancy) 필수 규칙
### 핵심 원칙
**모든 데이터 조회/생성/수정/삭제 로직은 반드시 회사별(company_code)로 격리되어야 합니다.**
이 시스템은 멀티테넌트 아키텍처를 사용하며, 각 회사(tenant)는 자신의 데이터만 접근할 수 있어야 합니다.
### 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_info(company_code)
);
-- 성능을 위한 인덱스 (필수)
CREATE INDEX idx_example_company_code ON example_table(company_code);
```
#### 예외 테이블
다음 테이블들만 `company_code` 없이 전역 데이터를 저장할 수 있습니다:
- `company_info` (회사 마스터 데이터)
- `user_info` (사용자는 company_code 보유)
- 시스템 설정 테이블 (`system_config` 등)
- 감사 로그 테이블 (`audit_log` 등)
### 2. 백엔드 API 구현 필수 사항
#### 조회(SELECT) 쿼리
**모든 SELECT 쿼리는 company_code 필터링을 반드시 포함해야 합니다:**
```typescript
// ✅ 올바른 방법
async function getDataList(req: Request, res: Response) {
const companyCode = req.user!.companyCode; // 인증된 사용자의 회사 코드
const query = `
SELECT * FROM example_table
WHERE company_code = $1
ORDER BY created_at DESC
`;
const result = await pool.query(query, [companyCode]);
logger.info("데이터 조회", {
companyCode,
rowCount: result.rowCount
});
return res.json({ success: true, data: result.rows });
}
// ❌ 잘못된 방법 - 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 });
}
```
#### 생성(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] });
}
// ❌ 잘못된 방법 - company_code 누락
async function createData(req: Request, res: Response) {
const { name, description } = req.body;
const query = `
INSERT INTO example_table (name, description)
VALUES ($1, $2)
`; // company_code 누락! 다른 회사 데이터와 섞임
const result = await pool.query(query, [name, description]);
return res.json({ success: true, data: result.rows[0] });
}
```
#### 수정(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;
const query = `
UPDATE example_table
SET name = $1, description = $2, updated_at = NOW()
WHERE id = $3 AND company_code = $4
RETURNING *
`;
const result = await pool.query(query, [name, description, id, companyCode]);
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] });
}
// ❌ 잘못된 방법 - 다른 회사 데이터도 수정 가능
async function updateData(req: Request, res: Response) {
const { id } = req.params;
const { name, description } = req.body;
const query = `
UPDATE example_table
SET name = $1, description = $2
WHERE id = $3
`; // 다른 회사의 같은 ID 데이터도 수정됨!
const result = await pool.query(query, [name, description, id]);
return res.json({ success: true, data: result.rows[0] });
}
```
#### 삭제(DELETE) 쿼리
**WHERE 절에 company_code를 반드시 포함해야 합니다:**
```typescript
// ✅ 올바른 방법
async function deleteData(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const query = `
DELETE FROM example_table
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터를 찾을 수 없거나 권한이 없습니다"
});
}
logger.info("데이터 삭제", { companyCode, id });
return res.json({ success: true });
}
// ❌ 잘못된 방법 - 다른 회사 데이터도 삭제 가능
async function deleteData(req: Request, res: Response) {
const { id } = req.params;
const query = `DELETE FROM example_table WHERE id = $1`;
const result = await pool.query(query, [id]);
return res.json({ success: true });
}
```
### 3. company_code = "*" 의미
**중요**: `company_code = "*"`는 **최고 관리자 전용 데이터**를 의미합니다.
- ❌ 잘못된 이해: `company_code = "*"` = 모든 회사가 공유하는 공통 데이터
- ✅ 올바른 이해: `company_code = "*"` = 최고 관리자만 관리하는 전용 데이터
**회사별 데이터 격리 원칙**:
- 회사 A (`company_code = "COMPANY_A"`): 회사 A 데이터만 조회/수정/삭제 가능
- 회사 B (`company_code = "COMPANY_B"`): 회사 B 데이터만 조회/수정/삭제 가능
- 최고 관리자 (`company_code = "*"`): 모든 회사 데이터 + 최고 관리자 전용 데이터 조회 가능
### 4. 최고 관리자(SUPER_ADMIN) 예외 처리
**최고 관리자(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 company_code, created_at DESC
`;
params = [];
logger.info("최고 관리자 전체 데이터 조회");
} else {
// 일반 회사: 자신의 회사 데이터만 조회 (company_code = "*" 데이터는 제외)
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 });
}
```
**핵심**: 일반 회사 사용자는 `company_code = "*"` 데이터를 볼 수 없습니다!
### 5. 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
`;
// ❌ 잘못된 방법 - 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
`;
```
### 6. 서비스 계층 패턴
**서비스 함수는 항상 companyCode를 첫 번째 파라미터로 받아야 합니다:**
```typescript
// ✅ 올바른 서비스 패턴
class ExampleService {
async findAll(companyCode: string, filters?: any) {
const query = `
SELECT * FROM example_table
WHERE company_code = $1
`;
return await pool.query(query, [companyCode]);
}
async findById(companyCode: string, id: number) {
const query = `
SELECT * FROM example_table
WHERE id = $1 AND company_code = $2
`;
const result = await pool.query(query, [id, companyCode]);
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. 프론트엔드 고려사항
프론트엔드에서는 직접 company_code를 다루지 않습니다. 백엔드 API가 자동으로 처리합니다.
```typescript
// ✅ 프론트엔드 - company_code 불필요
async function fetchData() {
const response = await apiClient.get("/api/example/list");
// 백엔드에서 자동으로 현재 사용자의 company_code로 필터링됨
return response.data;
}
// ❌ 프론트엔드에서 company_code를 수동으로 전달하지 않음
async function fetchData(companyCode: string) {
const response = await apiClient.get(`/api/example/list?companyCode=${companyCode}`);
return response.data;
}
```
### 8. 마이그레이션 체크리스트
새로운 테이블이나 기능을 추가할 때 반드시 확인하세요:
#### 데이터베이스
- [ ] 테이블에 `company_code VARCHAR(20) NOT NULL` 컬럼 추가
- [ ] `company_info` 테이블에 대한 외래키 제약조건 추가
- [ ] `company_code`에 인덱스 생성
- [ ] 샘플 데이터에 올바른 `company_code` 값 포함
#### 백엔드 API
- [ ] SELECT 쿼리에 `WHERE company_code = $1` 추가
- [ ] INSERT 쿼리에 `company_code` 컬럼 포함
- [ ] UPDATE/DELETE 쿼리의 WHERE 절에 `company_code` 조건 추가
- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 조건 추가
- [ ] 최고 관리자(`company_code = "*"`) 예외 처리
- [ ] 로그에 `companyCode` 정보 포함
#### 테스트
- [ ] 회사 A로 로그인하여 회사 A 데이터만 보이는지 확인
- [ ] 회사 B로 로그인하여 회사 B 데이터만 보이는지 확인
- [ ] 회사 A로 로그인하여 회사 B 데이터에 접근 불가능한지 확인
- [ ] 최고 관리자로 로그인하여 모든 데이터가 보이는지 확인
- [ ] 직접 SQL 인젝션 시도하여 다른 회사 데이터 접근 불가능 확인
### 9. 보안 주의사항
#### 클라이언트 입력 검증
```typescript
// ❌ 위험 - 클라이언트가 company_code를 지정할 수 있음
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]);
}
// ✅ 안전 - 인증된 사용자의 company_code만 사용
async function createData(req: Request, res: Response) {
const companyCode = req.user!.companyCode; // 서버에서 확정
const { name } = req.body;
const query = `INSERT INTO example_table (company_code, name) VALUES ($1, $2)`;
await pool.query(query, [companyCode, name]);
}
```
#### 감사 로그
모든 중요한 작업에 회사 정보를 로깅하세요:
```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: "다른 회사의 데이터 접근 시도",
});
```
### 10. 일반적인 실수와 해결방법
#### 실수 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: EXISTS 서브쿼리
```typescript
// ❌ 잘못된 방법
const query = `
SELECT * FROM example_table a
WHERE EXISTS (
SELECT 1 FROM related_table b WHERE b.example_id = a.id
)
AND a.company_code = $1
`;
// ✅ 올바른 방법
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
`;
```
### 11. 참고 자료
- 마이그레이션 파일: `db/migrations/033_add_company_code_to_code_tables.sql`
- 멀티테넌시 분석 문서: `docs/멀티테넌시_구현_현황_분석_보고서.md`
- 사용자 관리 컨트롤러: `backend-node/src/controllers/adminController.ts`
- 인증 미들웨어: `backend-node/src/middleware/authMiddleware.ts`
### 12. 요약
**모든 비즈니스 로직에서 회사별 데이터 격리는 필수입니다:**
1. 모든 테이블에 `company_code` 컬럼 추가
2. 모든 쿼리에 `company_code` 필터링 적용
3. 인증된 사용자의 `req.user.companyCode` 사용
4. 클라이언트 입력으로 `company_code`를 받지 않음
5. 최고 관리자(`company_code = "*"`)는 모든 데이터 조회 가능
6. **일반 회사는 `company_code = "*"` 데이터를 볼 수 없음** (최고 관리자 전용)
7. JOIN, 서브쿼리, 집계 함수에도 동일하게 적용
8. 모든 작업을 로깅하여 감사 추적 가능
**절대 잊지 마세요: 멀티테넌시는 보안의 핵심입니다!**
**company_code = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**