diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 4a6a1e03..5dd7dc21 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -144,8 +144,9 @@ export class FlowController { try { const { id } = req.params; 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) { res.status(404).json({ success: false, @@ -182,12 +183,13 @@ export class FlowController { const { id } = req.params; const flowId = parseInt(id); const { name, description, isActive } = req.body; + const userCompanyCode = (req as any).user?.companyCode; const flowDef = await this.flowDefinitionService.update(flowId, { name, description, isActive, - }); + }, userCompanyCode); if (!flowDef) { res.status(404).json({ @@ -217,8 +219,9 @@ export class FlowController { try { const { id } = req.params; 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) { res.status(404).json({ @@ -275,6 +278,7 @@ export class FlowController { try { const { flowId } = req.params; const flowDefinitionId = parseInt(flowId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -293,6 +297,16 @@ export class FlowController { 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({ flowDefinitionId, stepName, @@ -324,6 +338,7 @@ export class FlowController { try { const { stepId } = req.params; const id = parseInt(stepId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -342,6 +357,19 @@ export class FlowController { displayConfig, } = 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, { stepName, stepOrder, @@ -388,6 +416,20 @@ export class FlowController { try { const { stepId } = req.params; 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); @@ -446,6 +488,7 @@ export class FlowController { createConnection = async (req: Request, res: Response): Promise => { try { const { flowDefinitionId, fromStepId, toStepId, label } = req.body; + const userCompanyCode = (req as any).user?.companyCode; if (!flowDefinitionId || !fromStepId || !toStepId) { res.status(400).json({ @@ -455,6 +498,28 @@ export class FlowController { 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({ flowDefinitionId, fromStepId, @@ -482,6 +547,20 @@ export class FlowController { try { const { connectionId } = req.params; 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); @@ -670,23 +749,24 @@ export class FlowController { */ moveData = async (req: Request, res: Response): Promise => { try { - const { flowId, recordId, toStepId, note } = req.body; + const { flowId, fromStepId, recordId, toStepId, note } = req.body; const userId = (req as any).user?.userId || "system"; - if (!flowId || !recordId || !toStepId) { + if (!flowId || !fromStepId || !recordId || !toStepId) { res.status(400).json({ success: false, - message: "flowId, recordId, and toStepId are required", + message: "flowId, fromStepId, recordId, and toStepId are required", }); return; } await this.flowDataMoveService.moveDataToStep( flowId, - recordId, + fromStepId, toStepId, + recordId, userId, - note + note ? { note } : undefined ); res.json({ diff --git a/backend-node/src/services/flowConditionParser.ts b/backend-node/src/services/flowConditionParser.ts index 5f2e648a..c3a930ea 100644 --- a/backend-node/src/services/flowConditionParser.ts +++ b/backend-node/src/services/flowConditionParser.ts @@ -132,14 +132,23 @@ export class FlowConditionParser { /** * SQL 인젝션 방지를 위한 컬럼명 검증 */ - private static sanitizeColumnName(columnName: string): string { - // 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원) + static sanitizeColumnName(columnName: string): string { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { throw new Error(`Invalid column name: ${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; + } + /** * 조건 검증 */ diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 09058502..bec1d4d8 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -25,6 +25,7 @@ import { buildInsertQuery, buildSelectQuery, } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; @@ -236,18 +237,19 @@ export class FlowDataMoveService { ); } - const statusColumn = toStep.statusColumn; - const tableName = fromStep.tableName; + const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn); + const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName); // 추가 필드 업데이트 준비 const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const values: any[] = [dataId, toStep.statusValue]; let paramIndex = 3; - // 추가 데이터가 있으면 함께 업데이트 + // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함) if (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); paramIndex++; } @@ -276,33 +278,38 @@ export class FlowDataMoveService { dataId: any, additionalData?: Record ): Promise { - const sourceTable = fromStep.tableName; - const targetTable = toStep.targetTable || toStep.tableName; + const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName); + const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName); const fieldMappings = toStep.fieldMappings || {}; // 1. 소스 데이터 조회 const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const sourceResult = await client.query(selectQuery, [dataId]); - if (sourceResult.length === 0) { + if (sourceResult.rows.length === 0) { throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); } - const sourceData = sourceResult[0]; + const sourceData = sourceResult.rows[0]; // 2. 필드 매핑 적용 const mappedData: Record = {}; - // 매핑 정의가 있으면 적용 + // 매핑 정의가 있으면 적용 (컬럼명 검증) for (const [sourceField, targetField] of Object.entries(fieldMappings)) { + FlowConditionParser.sanitizeColumnName(sourceField); + FlowConditionParser.sanitizeColumnName(targetField as string); if (sourceData[sourceField] !== undefined) { mappedData[targetField as string] = sourceData[sourceField]; } } - // 추가 데이터 병합 + // 추가 데이터 병합 (키 검증) if (additionalData) { - Object.assign(mappedData, additionalData); + for (const [key, value] of Object.entries(additionalData)) { + const safeKey = FlowConditionParser.sanitizeColumnName(key); + mappedData[safeKey] = value; + } } // 3. 타겟 테이블에 데이터 삽입 @@ -321,7 +328,7 @@ export class FlowDataMoveService { `; 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 = - mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; + mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {}; // 새 단계 데이터 추가 stepDataMap[String(currentStepId)] = String(targetDataId); - if (mappingResult.length > 0) { + if (mappingResult.rows.length > 0) { // 기존 매핑 업데이트 const updateQuery = ` UPDATE flow_data_mapping @@ -366,7 +373,7 @@ export class FlowDataMoveService { await client.query(updateQuery, [ currentStepId, JSON.stringify(stepDataMap), - mappingResult[0].id, + mappingResult.rows[0].id, ]); } else { // 새 매핑 생성 diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 80c920ad..d43b2fe0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -19,7 +19,8 @@ export class FlowDefinitionService { userId: string, userCompanyCode?: string ): Promise { - const companyCode = request.companyCode || userCompanyCode || "*"; + // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용 + const companyCode = userCompanyCode || "*"; console.log("🔥 flowDefinitionService.create called with:", { name: request.name, @@ -118,10 +119,21 @@ export class FlowDefinitionService { /** * 플로우 정의 단일 조회 + * companyCode가 전달되면 해당 회사 소유 플로우만 반환 */ - async findById(id: number): Promise { - const query = "SELECT * FROM flow_definition WHERE id = $1"; - const result = await db.query(query, [id]); + async findById(id: number, companyCode?: string): Promise { + let query: string; + 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) { return null; @@ -132,10 +144,12 @@ export class FlowDefinitionService { /** * 플로우 정의 수정 + * companyCode가 전달되면 해당 회사 소유 플로우만 수정 가능 */ async update( id: number, - request: UpdateFlowDefinitionRequest + request: UpdateFlowDefinitionRequest, + companyCode?: string ): Promise { const fields: string[] = []; const params: any[] = []; @@ -160,18 +174,27 @@ export class FlowDefinitionService { } if (fields.length === 0) { - return this.findById(id); + return this.findById(id, companyCode); } 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 = ` UPDATE flow_definition SET ${fields.join(", ")} - WHERE id = $${paramIndex} + ${whereClause} RETURNING * `; - params.push(id); const result = await db.query(query, params); @@ -184,10 +207,21 @@ export class FlowDefinitionService { /** * 플로우 정의 삭제 + * companyCode가 전달되면 해당 회사 소유 플로우만 삭제 가능 */ - async delete(id: number): Promise { - const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; - const result = await db.query(query, [id]); + async delete(id: number, companyCode?: string): Promise { + let query: string; + 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; } diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 7a6825f0..54a668e6 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService"; import { FlowConditionParser } from "./flowConditionParser"; import { executeExternalQuery } from "./externalDbHelper"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; export class FlowExecutionService { private flowDefinitionService: FlowDefinitionService; @@ -42,7 +43,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -96,7 +97,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -267,11 +268,12 @@ export class FlowExecutionService { throw new Error(`Flow step not found: ${stepId}`); } - // 3. 테이블명 결정 - const tableName = step.tableName || flowDef.tableName; - if (!tableName) { + // 3. 테이블명 결정 (SQL 인젝션 방지) + const rawTableName = step.tableName || flowDef.tableName; + if (!rawTableName) { throw new Error("Table name not found"); } + const tableName = FlowConditionParser.sanitizeTableName(rawTableName); // 4. Primary Key 컬럼 결정 (기본값: id) const primaryKeyColumn = flowDef.primaryKey || "id"; @@ -280,8 +282,10 @@ export class FlowExecutionService { `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` ); - // 5. SET 절 생성 - const updateColumns = Object.keys(updateData); + // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지) + const updateColumns = Object.keys(updateData).map((col) => + FlowConditionParser.sanitizeColumnName(col) + ); if (updateColumns.length === 0) { throw new Error("No columns to update"); } diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 9f105a49..7cf9b9de 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -260,6 +260,7 @@ export interface FlowStepDataList { // 데이터 이동 요청 export interface MoveDataRequest { flowId: number; + fromStepId: number; recordId: string; toStepId: number; note?: string; diff --git a/frontend/components/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 61264ffb..8981e472 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; 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"; interface FlowDataListModalProps { @@ -102,15 +102,28 @@ export function FlowDataListModal({ try { setMovingData(true); - // 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) - const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); + // 다음 스텝 결정 (연결 정보에서 조회) + const connResponse = await getFlowConnections(flowId); + if (!connResponse.success || !connResponse.data) { + throw new Error("플로우 연결 정보를 가져올 수 없습니다"); + } + const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId); + if (!nextConn) { + throw new Error("다음 단계가 연결되어 있지 않습니다"); + } - // 데이터 이동 API 호출 - for (const dataId of selectedDataIds) { - const response = await moveDataToNextStep(flowId, stepId, dataId); - if (!response.success) { - throw new Error(`데이터 이동 실패: ${response.message}`); - } + // 선택된 행의 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}건의 데이터를 다음 단계로 이동했습니다`); diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index ff2a81a2..3cb835cd 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise> { return moveData({ flowId, - currentStepId, - dataId, + fromStepId, + recordId: String(recordId), + toStepId, }); } diff --git a/frontend/types/flow.ts b/frontend/types/flow.ts index 9cfa1eb2..878f8b35 100644 --- a/frontend/types/flow.ts +++ b/frontend/types/flow.ts @@ -235,6 +235,7 @@ export interface FlowStepDataList { export interface MoveDataRequest { flowId: number; + fromStepId: number; recordId: string; toStepId: number; note?: string;