회사별 메뉴 분리 및 권한 관리
This commit is contained in:
+502
@@ -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 = "*"는 공통 데이터가 아닌 최고 관리자 전용 데이터입니다!**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user