feat: Implement company code validation in flow management

- Enhanced the FlowController to include user company code validation for flow definitions, ensuring that users can only access and modify flows belonging to their company.
- Updated the FlowDefinitionService to accept company code as a parameter for create, update, and delete operations, enforcing ownership checks.
- Introduced sanitization methods in FlowConditionParser to prevent SQL injection for column and table names.
- Modified the FlowDataMoveService to validate table names and column names during data movement operations, enhancing security.
- Updated the frontend components to support batch data movement with proper validation and error handling.
This commit is contained in:
kjs 2026-03-03 10:38:38 +09:00
parent e2d88f01e3
commit fd5c61b12a
9 changed files with 207 additions and 56 deletions

View File

@ -144,8 +144,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const userCompanyCode = (req as any).user?.companyCode;
const definition = await this.flowDefinitionService.findById(flowId); const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode);
if (!definition) { if (!definition) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
@ -182,12 +183,13 @@ export class FlowController {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const { name, description, isActive } = req.body; const { name, description, isActive } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
const flowDef = await this.flowDefinitionService.update(flowId, { const flowDef = await this.flowDefinitionService.update(flowId, {
name, name,
description, description,
isActive, isActive,
}); }, userCompanyCode);
if (!flowDef) { if (!flowDef) {
res.status(404).json({ res.status(404).json({
@ -217,8 +219,9 @@ export class FlowController {
try { try {
const { id } = req.params; const { id } = req.params;
const flowId = parseInt(id); const flowId = parseInt(id);
const userCompanyCode = (req as any).user?.companyCode;
const success = await this.flowDefinitionService.delete(flowId); const success = await this.flowDefinitionService.delete(flowId, userCompanyCode);
if (!success) { if (!success) {
res.status(404).json({ res.status(404).json({
@ -275,6 +278,7 @@ export class FlowController {
try { try {
const { flowId } = req.params; const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId); const flowDefinitionId = parseInt(flowId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -293,6 +297,16 @@ export class FlowController {
return; return;
} }
// 플로우 소유권 검증
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(404).json({
success: false,
message: "Flow definition not found or access denied",
});
return;
}
const step = await this.flowStepService.create({ const step = await this.flowStepService.create({
flowDefinitionId, flowDefinitionId,
stepName, stepName,
@ -324,6 +338,7 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); const id = parseInt(stepId);
const userCompanyCode = (req as any).user?.companyCode;
const { const {
stepName, stepName,
stepOrder, stepOrder,
@ -342,6 +357,19 @@ export class FlowController {
displayConfig, displayConfig,
} = req.body; } = req.body;
// 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인
const existingStep = await this.flowStepService.findById(id);
if (existingStep) {
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const step = await this.flowStepService.update(id, { const step = await this.flowStepService.update(id, {
stepName, stepName,
stepOrder, stepOrder,
@ -388,6 +416,20 @@ export class FlowController {
try { try {
const { stepId } = req.params; const { stepId } = req.params;
const id = parseInt(stepId); const id = parseInt(stepId);
const userCompanyCode = (req as any).user?.companyCode;
// 스텝 소유권 검증
const existingStep = await this.flowStepService.findById(id);
if (existingStep) {
const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const success = await this.flowStepService.delete(id); const success = await this.flowStepService.delete(id);
@ -446,6 +488,7 @@ export class FlowController {
createConnection = async (req: Request, res: Response): Promise<void> => { createConnection = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowDefinitionId, fromStepId, toStepId, label } = req.body; const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
const userCompanyCode = (req as any).user?.companyCode;
if (!flowDefinitionId || !fromStepId || !toStepId) { if (!flowDefinitionId || !fromStepId || !toStepId) {
res.status(400).json({ res.status(400).json({
@ -455,6 +498,28 @@ export class FlowController {
return; return;
} }
// 플로우 소유권 검증
const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(404).json({
success: false,
message: "Flow definition not found or access denied",
});
return;
}
// fromStepId, toStepId가 해당 flow에 속하는지 검증
const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId);
if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId ||
!toStep || toStep.flowDefinitionId !== flowDefinitionId) {
res.status(400).json({
success: false,
message: "fromStepId and toStepId must belong to the specified flow",
});
return;
}
const connection = await this.flowConnectionService.create({ const connection = await this.flowConnectionService.create({
flowDefinitionId, flowDefinitionId,
fromStepId, fromStepId,
@ -482,6 +547,20 @@ export class FlowController {
try { try {
const { connectionId } = req.params; const { connectionId } = req.params;
const id = parseInt(connectionId); const id = parseInt(connectionId);
const userCompanyCode = (req as any).user?.companyCode;
// 연결 소유권 검증
const existingConn = await this.flowConnectionService.findById(id);
if (existingConn) {
const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode);
if (!flowDef) {
res.status(403).json({
success: false,
message: "Access denied: flow does not belong to your company",
});
return;
}
}
const success = await this.flowConnectionService.delete(id); const success = await this.flowConnectionService.delete(id);
@ -670,23 +749,24 @@ export class FlowController {
*/ */
moveData = async (req: Request, res: Response): Promise<void> => { moveData = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowId, recordId, toStepId, note } = req.body; const { flowId, fromStepId, recordId, toStepId, note } = req.body;
const userId = (req as any).user?.userId || "system"; const userId = (req as any).user?.userId || "system";
if (!flowId || !recordId || !toStepId) { if (!flowId || !fromStepId || !recordId || !toStepId) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: "flowId, recordId, and toStepId are required", message: "flowId, fromStepId, recordId, and toStepId are required",
}); });
return; return;
} }
await this.flowDataMoveService.moveDataToStep( await this.flowDataMoveService.moveDataToStep(
flowId, flowId,
recordId, fromStepId,
toStepId, toStepId,
recordId,
userId, userId,
note note ? { note } : undefined
); );
res.json({ res.json({

View File

@ -132,14 +132,23 @@ export class FlowConditionParser {
/** /**
* SQL * SQL
*/ */
private static sanitizeColumnName(columnName: string): string { static sanitizeColumnName(columnName: string): string {
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`); throw new Error(`Invalid column name: ${columnName}`);
} }
return columnName; return columnName;
} }
/**
* SQL
*/
static sanitizeTableName(tableName: string): string {
if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) {
throw new Error(`Invalid table name: ${tableName}`);
}
return tableName;
}
/** /**
* *
*/ */

View File

@ -25,6 +25,7 @@ import {
buildInsertQuery, buildInsertQuery,
buildSelectQuery, buildSelectQuery,
} from "./dbQueryBuilder"; } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowDataMoveService { export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -236,18 +237,19 @@ export class FlowDataMoveService {
); );
} }
const statusColumn = toStep.statusColumn; const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn);
const tableName = fromStep.tableName; const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName);
// 추가 필드 업데이트 준비 // 추가 필드 업데이트 준비
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
const values: any[] = [dataId, toStep.statusValue]; const values: any[] = [dataId, toStep.statusValue];
let paramIndex = 3; let paramIndex = 3;
// 추가 데이터가 있으면 함께 업데이트 // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함)
if (additionalData) { if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) { for (const [key, value] of Object.entries(additionalData)) {
updates.push(`${key} = $${paramIndex}`); const safeKey = FlowConditionParser.sanitizeColumnName(key);
updates.push(`${safeKey} = $${paramIndex}`);
values.push(value); values.push(value);
paramIndex++; paramIndex++;
} }
@ -276,33 +278,38 @@ export class FlowDataMoveService {
dataId: any, dataId: any,
additionalData?: Record<string, any> additionalData?: Record<string, any>
): Promise<any> { ): Promise<any> {
const sourceTable = fromStep.tableName; const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName);
const targetTable = toStep.targetTable || toStep.tableName; const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName);
const fieldMappings = toStep.fieldMappings || {}; const fieldMappings = toStep.fieldMappings || {};
// 1. 소스 데이터 조회 // 1. 소스 데이터 조회
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
const sourceResult = await client.query(selectQuery, [dataId]); const sourceResult = await client.query(selectQuery, [dataId]);
if (sourceResult.length === 0) { if (sourceResult.rows.length === 0) {
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
} }
const sourceData = sourceResult[0]; const sourceData = sourceResult.rows[0];
// 2. 필드 매핑 적용 // 2. 필드 매핑 적용
const mappedData: Record<string, any> = {}; const mappedData: Record<string, any> = {};
// 매핑 정의가 있으면 적용 // 매핑 정의가 있으면 적용 (컬럼명 검증)
for (const [sourceField, targetField] of Object.entries(fieldMappings)) { for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
FlowConditionParser.sanitizeColumnName(sourceField);
FlowConditionParser.sanitizeColumnName(targetField as string);
if (sourceData[sourceField] !== undefined) { if (sourceData[sourceField] !== undefined) {
mappedData[targetField as string] = sourceData[sourceField]; mappedData[targetField as string] = sourceData[sourceField];
} }
} }
// 추가 데이터 병합 // 추가 데이터 병합 (키 검증)
if (additionalData) { if (additionalData) {
Object.assign(mappedData, additionalData); for (const [key, value] of Object.entries(additionalData)) {
const safeKey = FlowConditionParser.sanitizeColumnName(key);
mappedData[safeKey] = value;
}
} }
// 3. 타겟 테이블에 데이터 삽입 // 3. 타겟 테이블에 데이터 삽입
@ -321,7 +328,7 @@ export class FlowDataMoveService {
`; `;
const insertResult = await client.query(insertQuery, values); const insertResult = await client.query(insertQuery, values);
return insertResult[0].id; return insertResult.rows[0].id;
} }
/** /**
@ -349,12 +356,12 @@ export class FlowDataMoveService {
]); ]);
const stepDataMap: Record<string, string> = const stepDataMap: Record<string, string> =
mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {};
// 새 단계 데이터 추가 // 새 단계 데이터 추가
stepDataMap[String(currentStepId)] = String(targetDataId); stepDataMap[String(currentStepId)] = String(targetDataId);
if (mappingResult.length > 0) { if (mappingResult.rows.length > 0) {
// 기존 매핑 업데이트 // 기존 매핑 업데이트
const updateQuery = ` const updateQuery = `
UPDATE flow_data_mapping UPDATE flow_data_mapping
@ -366,7 +373,7 @@ export class FlowDataMoveService {
await client.query(updateQuery, [ await client.query(updateQuery, [
currentStepId, currentStepId,
JSON.stringify(stepDataMap), JSON.stringify(stepDataMap),
mappingResult[0].id, mappingResult.rows[0].id,
]); ]);
} else { } else {
// 새 매핑 생성 // 새 매핑 생성

View File

@ -19,7 +19,8 @@ export class FlowDefinitionService {
userId: string, userId: string,
userCompanyCode?: string userCompanyCode?: string
): Promise<FlowDefinition> { ): Promise<FlowDefinition> {
const companyCode = request.companyCode || userCompanyCode || "*"; // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용
const companyCode = userCompanyCode || "*";
console.log("🔥 flowDefinitionService.create called with:", { console.log("🔥 flowDefinitionService.create called with:", {
name: request.name, name: request.name,
@ -118,10 +119,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async findById(id: number): Promise<FlowDefinition | null> { async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1"; let query: string;
const result = await db.query(query, [id]); let params: any[];
if (companyCode && companyCode !== "*") {
query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2";
params = [id, companyCode];
} else {
query = "SELECT * FROM flow_definition WHERE id = $1";
params = [id];
}
const result = await db.query(query, params);
if (result.length === 0) { if (result.length === 0) {
return null; return null;
@ -132,10 +144,12 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async update( async update(
id: number, id: number,
request: UpdateFlowDefinitionRequest request: UpdateFlowDefinitionRequest,
companyCode?: string
): Promise<FlowDefinition | null> { ): Promise<FlowDefinition | null> {
const fields: string[] = []; const fields: string[] = [];
const params: any[] = []; const params: any[] = [];
@ -160,18 +174,27 @@ export class FlowDefinitionService {
} }
if (fields.length === 0) { if (fields.length === 0) {
return this.findById(id); return this.findById(id, companyCode);
} }
fields.push(`updated_at = NOW()`); fields.push(`updated_at = NOW()`);
let whereClause = `WHERE id = $${paramIndex}`;
params.push(id);
paramIndex++;
if (companyCode && companyCode !== "*") {
whereClause += ` AND company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
}
const query = ` const query = `
UPDATE flow_definition UPDATE flow_definition
SET ${fields.join(", ")} SET ${fields.join(", ")}
WHERE id = $${paramIndex} ${whereClause}
RETURNING * RETURNING *
`; `;
params.push(id);
const result = await db.query(query, params); const result = await db.query(query, params);
@ -184,10 +207,21 @@ export class FlowDefinitionService {
/** /**
* *
* companyCode가
*/ */
async delete(id: number): Promise<boolean> { async delete(id: number, companyCode?: string): Promise<boolean> {
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; let query: string;
const result = await db.query(query, [id]); let params: any[];
if (companyCode && companyCode !== "*") {
query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id";
params = [id, companyCode];
} else {
query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
params = [id];
}
const result = await db.query(query, params);
return result.length > 0; return result.length > 0;
} }

View File

@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser"; import { FlowConditionParser } from "./flowConditionParser";
import { executeExternalQuery } from "./externalDbHelper"; import { executeExternalQuery } from "./externalDbHelper";
import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowExecutionService { export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService; private flowDefinitionService: FlowDefinitionService;
@ -42,7 +43,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -96,7 +97,7 @@ export class FlowExecutionService {
} }
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName; const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName);
// 4. 조건 JSON을 SQL WHERE절로 변환 // 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere( const { where, params } = FlowConditionParser.toSqlWhere(
@ -267,11 +268,12 @@ export class FlowExecutionService {
throw new Error(`Flow step not found: ${stepId}`); throw new Error(`Flow step not found: ${stepId}`);
} }
// 3. 테이블명 결정 // 3. 테이블명 결정 (SQL 인젝션 방지)
const tableName = step.tableName || flowDef.tableName; const rawTableName = step.tableName || flowDef.tableName;
if (!tableName) { if (!rawTableName) {
throw new Error("Table name not found"); throw new Error("Table name not found");
} }
const tableName = FlowConditionParser.sanitizeTableName(rawTableName);
// 4. Primary Key 컬럼 결정 (기본값: id) // 4. Primary Key 컬럼 결정 (기본값: id)
const primaryKeyColumn = flowDef.primaryKey || "id"; const primaryKeyColumn = flowDef.primaryKey || "id";
@ -280,8 +282,10 @@ export class FlowExecutionService {
`🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}`
); );
// 5. SET 절 생성 // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지)
const updateColumns = Object.keys(updateData); const updateColumns = Object.keys(updateData).map((col) =>
FlowConditionParser.sanitizeColumnName(col)
);
if (updateColumns.length === 0) { if (updateColumns.length === 0) {
throw new Error("No columns to update"); throw new Error("No columns to update");
} }

View File

@ -260,6 +260,7 @@ export interface FlowStepDataList {
// 데이터 이동 요청 // 데이터 이동 요청
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;

View File

@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; import { Loader2, AlertCircle, ArrowRight } from "lucide-react";
import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow"; import { getStepDataList, moveBatchData, getFlowConnections } from "@/lib/api/flow";
import { toast } from "sonner"; import { toast } from "sonner";
interface FlowDataListModalProps { interface FlowDataListModalProps {
@ -102,15 +102,28 @@ export function FlowDataListModal({
try { try {
setMovingData(true); setMovingData(true);
// 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) // 다음 스텝 결정 (연결 정보에서 조회)
const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); const connResponse = await getFlowConnections(flowId);
if (!connResponse.success || !connResponse.data) {
// 데이터 이동 API 호출 throw new Error("플로우 연결 정보를 가져올 수 없습니다");
for (const dataId of selectedDataIds) {
const response = await moveDataToNextStep(flowId, stepId, dataId);
if (!response.success) {
throw new Error(`데이터 이동 실패: ${response.message}`);
} }
const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId);
if (!nextConn) {
throw new Error("다음 단계가 연결되어 있지 않습니다");
}
// 선택된 행의 ID 추출
const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id));
// 배치 이동 API 호출
const response = await moveBatchData({
flowId,
fromStepId: stepId,
toStepId: nextConn.toStepId,
dataIds: selectedDataIds,
});
if (!response.success) {
throw new Error(`데이터 이동 실패: ${response.error || "알 수 없는 오류"}`);
} }
toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`); toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);

View File

@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ suc
*/ */
export async function moveDataToNextStep( export async function moveDataToNextStep(
flowId: number, flowId: number,
currentStepId: number, fromStepId: number,
dataId: number, toStepId: number,
recordId: string | number,
): Promise<ApiResponse<{ success: boolean }>> { ): Promise<ApiResponse<{ success: boolean }>> {
return moveData({ return moveData({
flowId, flowId,
currentStepId, fromStepId,
dataId, recordId: String(recordId),
toStepId,
}); });
} }

View File

@ -235,6 +235,7 @@ export interface FlowStepDataList {
export interface MoveDataRequest { export interface MoveDataRequest {
flowId: number; flowId: number;
fromStepId: number;
recordId: string; recordId: string;
toStepId: number; toStepId: number;
note?: string; note?: string;