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 {
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<void> => {
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<void> => {
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({

View File

@ -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;
}
/**
*
*/

View File

@ -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<string, any>
): Promise<any> {
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<string, any> = {};
// 매핑 정의가 있으면 적용
// 매핑 정의가 있으면 적용 (컬럼명 검증)
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<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);
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 {
// 새 매핑 생성

View File

@ -19,7 +19,8 @@ export class FlowDefinitionService {
userId: string,
userCompanyCode?: string
): Promise<FlowDefinition> {
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<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1";
const result = await db.query(query, [id]);
async findById(id: number, companyCode?: string): Promise<FlowDefinition | null> {
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<FlowDefinition | null> {
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<boolean> {
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<boolean> {
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;
}

View File

@ -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");
}

View File

@ -260,6 +260,7 @@ export interface FlowStepDataList {
// 데이터 이동 요청
export interface MoveDataRequest {
flowId: number;
fromStepId: number;
recordId: string;
toStepId: number;
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 { 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}건의 데이터를 다음 단계로 이동했습니다`);

View File

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

View File

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