From ff95c1950e2a3943328a2d0aa9c8d41051b49650 Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 15:04:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=EA=B4=80?= =?UTF-8?q?=EB=A6=AC):=20=EC=A4=91=EC=9A=94=204=EA=B1=B4=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=84=20=EC=88=98=EC=A0=95=20(PR-C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. getColumnGroup metaCols 확장 — 시스템 자동 생성 컬럼이 5개만 인식되어 사용자 컬럼에 섞여 보이던 문제. objid / tenant_id / creator / modifier / created_at / updated_at 추가 (총 11개). 2. updateColumnWebType 회사코드 격리 — 모든 호출이 company_code='*' 로 저장돼 회사 관리자가 자기 회사 전용 web_type 변경 시 모든 회사 공통 설정을 건드림. 컨트롤러에서 @RequestAttribute("company_code") 받아 service 에 전달. 3. validateUniqueConstraints N+1 해소 — hasColumn 이 루프 안에서 매번 호출되어 UNIQUE 컬럼 N 개일 때 N 번의 information_schema 조회. 루프 밖으로 빼서 1 번. 4. sanitize 강화 — 빈 문자열 / 숫자 시작 / 63자 초과 / SQL 예약어 모두 IllegalArgumentException 으로 거부. 빈 식별자가 동적 SQL 에 끼어들어 500 에러 노출되던 패턴 방지. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controller/TableManagementController.java | 6 ++- .../erp/service/TableManagementService.java | 47 ++++++++++++++++--- frontend/components/admin/table-type/types.ts | 18 ++++++- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java index 9e531cf7..3f091bb6 100644 --- a/backend-spring/src/main/java/com/erp/controller/TableManagementController.java +++ b/backend-spring/src/main/java/com/erp/controller/TableManagementController.java @@ -187,7 +187,8 @@ public class TableManagementController { @PathVariable String tableName, @PathVariable String columnName, @RequestBody Map body, - @RequestAttribute("role") String role) { + @RequestAttribute("role") String role, + @RequestAttribute("company_code") String companyCode) { if (!isAdmin(role)) { return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다.")); } @@ -197,7 +198,8 @@ public class TableManagementController { } @SuppressWarnings("unchecked") Map detailSettings = (Map) body.get("detail_settings"); - tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings); + // 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장 + tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode); return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다.")); } diff --git a/backend-spring/src/main/java/com/erp/service/TableManagementService.java b/backend-spring/src/main/java/com/erp/service/TableManagementService.java index 99207fee..83f61aee 100644 --- a/backend-spring/src/main/java/com/erp/service/TableManagementService.java +++ b/backend-spring/src/main/java/com/erp/service/TableManagementService.java @@ -215,19 +215,21 @@ public class TableManagementService extends BaseService { @Transactional public void updateColumnWebType(String tableName, String columnName, - String webType, Map detailSettings) { + String webType, Map detailSettings, + String companyCode) { String finalType = normalizeInputType(webType); Map params = new HashMap<>(); params.put("table_name", tableName); params.put("column_name", columnName); params.put("input_type", finalType); params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}"); - params.put("company_code", "*"); + // 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정 + params.put("company_code", companyCode != null ? companyCode : "*"); params.put("clear_entity", false); params.put("clear_code", false); params.put("clear_category", false); sqlSession.update(NS + "upsertColumnInputType", params); - log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType); + log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode); } @Transactional @@ -398,12 +400,14 @@ public class TableManagementService extends BaseService { String safeTable = sanitize(tableName); List violations = new ArrayList<>(); + // N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행. + boolean hasCompanyCode = hasColumn(safeTable, "company_code"); + for (Map col : uniqueCols) { String colName = (String) col.get("column_name"); Object val = data.get(colName); if (val == null) continue; - boolean hasCompanyCode = hasColumn(safeTable, "company_code"); String sql; List sqlParams = new ArrayList<>(); @@ -1267,9 +1271,40 @@ public class TableManagementService extends BaseService { } /** SQL injection 방지용 식별자 정리 */ + /** + * SQL 식별자(테이블/컬럼명) 살균. + * - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙) + * - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException + * + * 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해 + * 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김. + */ + private static final java.util.Set SQL_RESERVED_WORDS = java.util.Set.of( + "user", "order", "group", "table", "column", "index", "select", "insert", + "update", "delete", "from", "where", "join", "on", "as", "and", "or", "not", + "null", "true", "false", "create", "alter", "drop", "primary", "key", + "foreign", "references", "constraint", "default", "unique", "check", + "view", "procedure", "function" + ); + private String sanitize(String name) { - if (name == null) return ""; - return name.replaceAll("[^a-zA-Z0-9_]", ""); + if (name == null) { + throw new IllegalArgumentException("식별자가 null 입니다."); + } + String cleaned = name.replaceAll("[^a-zA-Z0-9_]", ""); + if (cleaned.isEmpty()) { + throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name); + } + if (cleaned.length() > 63) { + throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned); + } + if (Character.isDigit(cleaned.charAt(0))) { + throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned); + } + if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) { + throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다."); + } + return cleaned; } /** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */ diff --git a/frontend/components/admin/table-type/types.ts b/frontend/components/admin/table-type/types.ts index 374a5dfb..c936c9e1 100644 --- a/frontend/components/admin/table-type/types.ts +++ b/frontend/components/admin/table-type/types.ts @@ -108,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE {} as Record, ); -/** 컬럼 그룹 판별 */ +/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */ export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup { - const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"]; + // 시스템 컬럼: invyone 자동 생성 (id/날짜/작성자/회사) 외에 VEX 계승 (objid), 멀티테넌트 (tenant_id), + // 수정자/생성자 변형 (creator/modifier/created_at/updated_at) 까지 모두 포함 + const metaCols = [ + "id", + "objid", + "tenant_id", + "created_date", + "updated_date", + "created_at", + "updated_at", + "writer", + "creator", + "modifier", + "company_code", + ]; if (metaCols.includes(col.column_name)) return "meta"; if (["entity", "code", "category"].includes(col.input_type)) return "reference"; return "basic"; -- 2.52.0